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.
recyclarr
Robert Dailey 3 years ago
parent 59934be5d4
commit c21fc51b23

@ -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

@ -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

@ -23,6 +23,7 @@
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Condition=" '$(DisableNbgv)' != 'true' " />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Include="JetBrains.Annotations" />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">
@ -32,10 +33,12 @@
<PackageReference Include="NSubstitute" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="FluentAssertions.Json" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="AutofacContrib.NSubstitute" />
<PackageReference Include="GitHubActionsTestLogger" />
<PackageReference Include="Serilog.Sinks.TestCorrelator" />
<PackageReference Include="Serilog.Sinks.NUnit" />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">

@ -2,6 +2,7 @@
<ItemGroup>
<!-- Test Packages -->
<PackageReference Update="AutofacContrib.NSubstitute" Version="7.*" />
<PackageReference Update="FluentAssertions.Json" Version="5.*" />
<PackageReference Update="FluentAssertions" Version="5.*" />
<PackageReference Update="GitHubActionsTestLogger" Version="1.*" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="16.*" />
@ -10,10 +11,13 @@
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
<PackageReference Update="NUnit" Version="3.*" />
<PackageReference Update="NUnit3TestAdapter" Version="3.*" />
<PackageReference Update="Serilog.Sinks.NUnit" Version="1.*" />
<PackageReference Update="Serilog.Sinks.TestCorrelator" Version="3.*" />
<PackageReference Update="morelinq" Version="3.*" />
<!-- Non-Test Packages -->
<PackageReference Update="Autofac.Extensions.DependencyInjection" Version="7.*" />
<PackageReference Update="Autofac.Extras.AggregateService" Version="6.*" />
<PackageReference Update="Autofac" Version="6.*" />
<PackageReference Update="CliFx" Version="2.*" />
<PackageReference Update="Flurl.Http" Version="3.*" />
@ -29,8 +33,8 @@
</PackageReference>
<PackageReference Update="Serilog.Sinks.Console" Version="3.*" />
<PackageReference Update="Serilog" Version="2.*" />
<PackageReference Update="System.Data.HashFunction.FNV" Version="2.*" />
<PackageReference Update="System.IO.Abstractions" Version="13.*" />
<PackageReference Update="YamlDotNet" Version="10.*" />
<PackageReference Update="System.Data.HashFunction.FNV" Version="2.*" />
</ItemGroup>
</Project>

@ -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;
}
}
}

@ -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<T>(Action<T> action)
{
return ArgumentMatcher.Enqueue(new AssertionMatcher<T>(action));
}
private class AssertionMatcher<T> : IArgumentMatcher<T>
{
private readonly Action<T> _assertion;
public AssertionMatcher(Action<T> 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;
}
}
}
}

@ -3,6 +3,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NUnit" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="FluentAssertions.Json" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />

@ -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));
}

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;

@ -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<TestServiceConfiguration>(
Substitute.For<IConfigurationProvider>(),
@ -42,15 +42,15 @@ test_service:
Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName);
act.Should().Throw<YamlException>()
.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<TestServiceConfiguration>(
Substitute.For<IConfigurationProvider>(),
@ -60,7 +60,7 @@ test_service:
Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName);
act.Should().Throw<YamlException>()
.WithMessage("*Property 'api_key' is required");
.WithMessage("*Property 'base_url' is required");
}
}
}

@ -21,7 +21,7 @@ namespace Trash.Tests.CreateConfig
var filesystem = Substitute.For<IFileSystem>();
var cmd = new CreateConfigCommand(logger, filesystem);
await cmd.ExecuteAsync(Substitute.For<IConsole>());
await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false);
filesystem.File.Received().Exists(Arg.Is<string>(s => s.EndsWith("trash.yml")));
filesystem.File.Received().WriteAllText(Arg.Is<string>(s => s.EndsWith("trash.yml")), Arg.Any<string>());
@ -37,7 +37,7 @@ namespace Trash.Tests.CreateConfig
Path = "some/other/path.yml"
};
await cmd.ExecuteAsync(Substitute.For<IConsole>());
await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false);
filesystem.File.Received().Exists(Arg.Is("some/other/path.yml"));
filesystem.File.Received().WriteAllText(Arg.Is("some/other/path.yml"), Arg.Any<string>());

@ -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<ILogger>();
ServiceCache = Substitute.For<IServiceCache>();
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<CustomFormatCache>().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<CustomFormatCache>().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<object>());
}
[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<TrashIdMapping> {new("", "") {CustomFormatId = 5}}
};
ctx.ServiceCache.Load<CustomFormatCache>().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<ProcessedCustomFormatData>
{
new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)}
};
ctx.Persister.Update(customFormatData);
ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache
{
TrashIdMappings = new List<TrashIdMapping> {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<ProcessedCustomFormatData>());
ctx.Persister.CfCache.Should().NotBeNull();
}
}
}

@ -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<string>
// {
// context.ResourceData.ReadData("ImportableCustomFormat1.json"),
// context.ResourceData.ReadData("ImportableCustomFormat2.json")
// };
//
// var logger = Substitute.For<ILogger>();
// var guideParser = Substitute.For<ICustomFormatGuideParser>();
// var updater = new CustomFormatUpdater(logger, guideParser);
//
// guideParser.ParseMarkdown(Arg.Any<string>()).Returns(testJsonList);
//
// var args = Substitute.For<IRadarrCommand>();
// args.Preview.Returns(true);
// var config = new RadarrConfiguration();
//
// var output = new StringWriter();
// Console.SetOut(output);
//
// updater.Process(args, config);
//
// var expectedOutput = new List<string>
// {
// // language=regex
// @"Surround Sound\s+43bb5f09c79641e7a22e48d440bd8868",
// // language=regex
// @"DTS-HD/DTS:X\s+4eb3c272d48db8ab43c2c85283b69744"
// };
//
// foreach (var expectedLine in expectedOutput)
// {
// output.ToString().Should().MatchRegex(expectedLine);
// }
// }
// }
// }

@ -0,0 +1,71 @@

<sub><sub><sub>Score [480]</sub>
??? 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))"
}
}]
}
```
<sub><sup>[TOP](#index)</sup>
------
### 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.
<sub><sub><sub>Score [500]</sub>
??? 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"
}
```

@ -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)"
}
}]
}

@ -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)"
}
}]
}

@ -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))"
}
}]
}

@ -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))"
}
}]
}

@ -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<CustomFormatConfig>
{
new()
{
Names = new List<string> {"Surround SOUND", "DTS-HD/DTS:X", "no score", "not in guide 1"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = -1234}
}
},
new()
{
Names = new List<string> {"no score", "not in guide 2"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile3"},
new() {Name = "profile4", Score = 5678}
}
}
};
guideProcessor.BuildGuideData(config, null);
var expectedProcessedCustomFormatData = new List<ProcessedCustomFormatData>
{
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<ProcessedConfigData>
{
new()
{
CustomFormats = expectedProcessedCustomFormatData,
QualityProfiles = config[0].QualityProfiles
},
new()
{
CustomFormats = expectedProcessedCustomFormatData.GetRange(2, 1),
QualityProfiles = config[1].QualityProfiles
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
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<string>
{
"not in guide 1", "not in guide 2"
});
guideProcessor.ProfileScores.Should()
.BeEquivalentTo(new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
{
{
"profile1", new List<QualityProfileCustomFormatScoreEntry>
{
new(expectedProcessedCustomFormatData[0], 500),
new(expectedProcessedCustomFormatData[1], 480)
}
},
{
"profile2", new List<QualityProfileCustomFormatScoreEntry>
{
new(expectedProcessedCustomFormatData[0], -1234),
new(expectedProcessedCustomFormatData[1], -1234),
new(expectedProcessedCustomFormatData[2], -1234)
}
},
{
"profile4", new List<QualityProfileCustomFormatScoreEntry>
{
new(expectedProcessedCustomFormatData[2], 5678)
}
}
}, op => op.Using(new JsonEquivalencyStep()));
}
}
}

@ -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<ProcessedCustomFormatData>
{
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<string> {"name1", "name3"},
QualityProfiles = new List<QualityProfileConfig>
{
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<ProcessedConfigData>
{
new()
{
CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Cache_names_are_used_instead_of_name_in_json_data()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
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<string> {"name1"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{testProcessedCfs[1]}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Custom_formats_missing_from_config_are_skipped()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
new("name1", "", new JObject()),
new("name2", "", new JObject())
};
var testConfig = new CustomFormatConfig[]
{
new()
{
Names = new List<string> {"name1"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.RenamedCustomFormats.Should().BeEmpty();
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "", new JObject())
}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
new("name1", "", new JObject()),
new("name2", "", new JObject())
};
var testConfig = new CustomFormatConfig[]
{
new()
{
Names = new List<string> {"name1", "name3"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.RenamedCustomFormats.Should().BeEmpty();
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "", new JObject())
}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Custom_formats_with_same_trash_id_and_same_name_in_cache_are_in_renamed_list()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
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<string> {"name1", "name2"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.RenamedCustomFormats.Should().BeEquivalentTo(testProcessedCfs, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = testProcessedCfs
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
}
}

@ -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<CustomFormatData> 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<CustomFormatConfig>
{
new() {Names = new List<string> {"name1"}}
};
var testCache = new CustomFormatCache
{
TrashIdMappings = new List<TrashIdMapping>
{
new("id1", "name1")
}
};
var testGuideData = new List<CustomFormatData>
{
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<ProcessedCustomFormatData>
{
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<CustomFormatData>
{
new()
{
Json = @"{'name': 'name1', 'trash_id': 'id1'}"
}
};
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"name1"}}
};
var testCache = new CustomFormatCache
{
TrashIdMappings = new List<TrashIdMapping>
{
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<ProcessedCustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"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<ProcessedCustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"name1", "name3"}},
new() {Names = new List<string> {"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<ProcessedCustomFormatData>
{
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<CustomFormatData>
{
new()
{
Json = @"{'name': 'name1', 'trash_id': 'id1'}"
}
};
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"name1"}}
};
var testCache = new CustomFormatCache
{
TrashIdMappings = new List<TrashIdMapping> {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<ProcessedCustomFormatData>
{
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<TrashIdMapping> {new("id1", "3D", 9)}
};
var guideCfs = new List<CustomFormatData>
{
new() {Json = "{'name': '3D', 'trash_id': 'id1'}"}
};
var processor = new CustomFormatStep();
processor.Process(guideCfs, Array.Empty<CustomFormatConfig>(), 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<CustomFormatData>
{
new()
{
Json = @"{'name': 'name2', 'trash_id': 'id1'}"
}
};
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"name2"}}
};
var testCache = new CustomFormatCache
{
TrashIdMappings = new List<TrashIdMapping> {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<CustomFormatConfig>
{
new() {Names = new List<string> {"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<ProcessedCustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"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();
}
}
}

@ -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<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "id1", new JObject()) {Score = null}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should().BeEmpty();
processor.CustomFormatsWithoutScore.Should().Equal(new List<object> {("name1", "id1", "profile1")});
}
[Test]
public void Overwrite_score_from_guide_if_config_defines_score()
{
var testConfigData = new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("", "id1", new JObject()) {Score = 100}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should().ContainKey("profile1")
.WhichValue.Should().BeEquivalentTo(new List<QualityProfileCustomFormatScoreEntry>
{
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<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("", "id1", new JObject()) {Score = 100}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = null}
}
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var expectedScoreEntries = new List<QualityProfileCustomFormatScoreEntry>
{
new(testConfigData[0].CustomFormats[0], 100)
};
processor.ProfileScores.Should().BeEquivalentTo(
new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
{
{"profile1", expectedScoreEntries},
{"profile2", expectedScoreEntries}
});
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
[Test]
public void Zero_score_is_not_ignored()
{
var testConfigData = new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "id1", new JObject()) {Score = 0}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should().ContainKey("profile1")
.WhichValue.Should().BeEquivalentTo(new List<QualityProfileCustomFormatScoreEntry>
{
new(testConfigData[0].CustomFormats[0], 0)
});
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
}
}

@ -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<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var config = new RadarrConfiguration {DeleteOldCustomFormats = true};
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = new Collection<TrashIdMapping>();
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any<List<JObject>>());
}
[Test]
public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var config = new RadarrConfiguration(); // DeleteOldCustomFormats should default to false
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.DidNotReceive()
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
}
}
}

@ -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<ICustomFormatService>();
var processor = new CustomFormatApiPersistenceStep();
await processor.Process(api, transactions);
Received.InOrder(() =>
{
api.CreateCustomFormat(transactions.NewCustomFormats.First());
api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First());
api.DeleteCustomFormat(4);
});
}
}
}

@ -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<ProcessedCustomFormatData>
{
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<List<JObject>>(@"[{
'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<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData>
{
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<TrashIdMapping>
{
new("", "") {CustomFormatId = 2}
};
var guideCfs = new List<ProcessedCustomFormatData>
{
new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}}
};
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(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<TrashIdMapping>
{
new("testtrashid", "testname") {CustomFormatId = 2},
new("", "not_deleted") {CustomFormatId = 3}
};
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(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);
}
}
}

@ -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<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
{
{"wrong_profile_name", new List<QualityProfileCustomFormatScoreEntry>()}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
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<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
{
{
"profile1", new List<QualityProfileCustomFormatScoreEntry>
{
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<JObject>(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]);
}
}
}

@ -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<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
act.Should().Throw<YamlException>();
}
[Test]
public void Quality_definition_type_is_required()
{
const string yaml = @"
radarr:
@ -35,5 +56,27 @@ radarr:
act.Should().Throw<YamlException>()
.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<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
act.Should().Throw<YamlException>();
}
}
}

@ -7,4 +7,8 @@
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Radarr\CustomFormat\Processors\Data\CF_Markdown1.md" />
</ItemGroup>
</Project>

@ -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");
}
}

@ -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");
}
}
}

@ -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<RadarrApi>().As<IRadarrApi>();
// Api Services
builder.RegisterType<QualityDefinitionService>().As<IQualityDefinitionService>();
builder.RegisterType<CustomFormatService>().As<ICustomFormatService>();
builder.RegisterType<QualityProfileService>().As<IQualityProfileService>();
// Quality Definition Support
builder.RegisterType<RadarrQualityDefinitionUpdater>();
builder.RegisterType<RadarrQualityDefinitionGuideParser>().As<IRadarrQualityDefinitionGuideParser>();
// Custom Format Support
builder.RegisterType<CustomFormatUpdater>().As<ICustomFormatUpdater>();
builder.RegisterType<CustomFormatGuideParser>().As<ICustomFormatGuideParser>();
builder.RegisterType<CachePersister>().As<ICachePersister>();
// Guide Processor
builder.RegisterType<GuideProcessor>().As<IGuideProcessor>();
builder.RegisterAggregateService<IGuideProcessorSteps>();
builder.RegisterType<CustomFormatStep>().As<ICustomFormatStep>();
builder.RegisterType<ConfigStep>().As<IConfigStep>();
builder.RegisterType<QualityProfileStep>().As<IQualityProfileStep>();
// Persistence Processor
builder.RegisterType<PersistenceProcessor>().As<IPersistenceProcessor>();
builder.RegisterAggregateService<IPersistenceProcessorSteps>();
builder.RegisterType<JsonTransactionStep>().As<IJsonTransactionStep>();
builder.RegisterType<CustomFormatApiPersistenceStep>().As<ICustomFormatApiPersistenceStep>();
builder.RegisterType<QualityProfileApiPersistenceStep>().As<IQualityProfileApiPersistenceStep>();
}
private static void ConfigurationRegistrations(ContainerBuilder builder)

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Trash.Extensions
{
internal static class LinqExtensions
{
internal static IEnumerable<TResult> FullOuterGroupJoin<TA, TB, TKey, TResult>(
this IEnumerable<TA> a,
IEnumerable<TB> b,
Func<TA, TKey> selectKeyA,
Func<TB, TKey> selectKeyB,
Func<IEnumerable<TA>, IEnumerable<TB>, TKey, TResult> projection,
IEqualityComparer<TKey>? cmp = null)
{
cmp ??= EqualityComparer<TKey>.Default;
var alookup = a.ToLookup(selectKeyA, cmp);
var blookup = b.ToLookup(selectKeyB, cmp);
var keys = new HashSet<TKey>(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<TResult> FullOuterJoin<TA, TB, TKey, TResult>(
this IEnumerable<TA> a,
IEnumerable<TB> b,
Func<TA, TKey> selectKeyA,
Func<TB, TKey> selectKeyB,
Func<TA, TB, TKey, TResult> projection,
TA? defaultA = default,
TB? defaultB = default,
IEqualityComparer<TKey>? cmp = null)
{
cmp ??= EqualityComparer<TKey>.Default;
var alookup = a.ToLookup(selectKeyA, cmp);
var blookup = b.ToLookup(selectKeyB, cmp);
var keys = new HashSet<TKey>(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;
}
}
}

@ -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);
}

@ -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<List<JObject>> GetCustomFormats()
{
return await BaseUrl()
.AppendPathSegment("customformat")
.GetJsonAsync<List<JObject>>();
}
public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
{
var response = await BaseUrl()
.AppendPathSegment("customformat")
.PostJsonAsync(cf.Json)
.ReceiveJson<JObject>();
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<JObject>();
}
public async Task DeleteCustomFormat(int customFormatId)
{
await BaseUrl()
.AppendPathSegment($"customformat/{customFormatId}")
.DeleteAsync();
}
private string BaseUrl()
{
return _serviceConfig.BuildUrl();
}
}
}

@ -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<List<JObject>> GetCustomFormats();
Task CreateCustomFormat(ProcessedCustomFormatData cf);
Task UpdateCustomFormat(ProcessedCustomFormatData cf);
Task DeleteCustomFormat(int customFormatId);
}
}

@ -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<List<JObject>> GetQualityProfiles();
Task<JObject> UpdateQualityProfile(JObject profileJson, int id);
}
}

@ -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<List<JObject>> GetQualityProfiles()
{
return await BaseUrl
.AppendPathSegment("qualityprofile")
.GetJsonAsync<List<JObject>>();
}
public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id)
{
return await BaseUrl
.AppendPathSegment($"qualityprofile/{id}")
.PutJsonAsync(profileJson)
.ReceiveJson<JObject>();
}
}
}

@ -0,0 +1,10 @@
namespace Trash.Radarr.CustomFormat
{
public enum ApiOperationType
{
Create,
Update,
NoChange,
Delete
}
}

@ -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<CustomFormatCache>();
// 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<ProcessedCustomFormatData> customFormats)
{
Log.Debug("Updating cache");
CfCache = new CustomFormatCache();
CfCache!.TrashIdMappings.AddRange(customFormats
.Where(cf => cf.CacheEntry != null)
.Select(cf => cf.CacheEntry!));
}
}
}

@ -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("");
}
}
}

@ -0,0 +1,8 @@
namespace Trash.Radarr.CustomFormat.Guide
{
public class CustomFormatData
{
public int? Score { get; set; } = null;
public string Json { get; set; } = "";
}
}

@ -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<string> GetMarkdownData()
{
return await
"https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Radarr/V3/Radarr-collection-of-custom-formats.md"
.GetStringAsync();
}
public IList<CustomFormatData> 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));
// }
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Trash.Radarr.CustomFormat.Guide
{
public interface ICustomFormatGuideParser
{
Task<string> GetMarkdownData();
IList<CustomFormatData> ParseMarkdown(string markdown);
}
}

@ -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<CustomFormatData> Results { get; } = new();
public StringWriter JsonStream { get; } = new();
public void ResetParserState()
{
CodeBlockIndentation = null;
JsonStream.GetStringBuilder().Clear();
Score = null;
}
}
}

@ -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<ProcessedCustomFormatData> customFormats);
}
}

@ -0,0 +1,10 @@
using System.Threading.Tasks;
using Trash.Command;
namespace Trash.Radarr.CustomFormat
{
public interface ICustomFormatUpdater
{
Task Process(IServiceCommand args, RadarrConfiguration config);
}
}

@ -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<TrashIdMapping> 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; }
}
}

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Trash.Radarr.CustomFormat.Models
{
public class ProcessedConfigData
{
public List<ProcessedCustomFormatData> CustomFormats { get; init; } = new();
public List<QualityProfileConfig> QualityProfiles { get; init; } = new();
}
}

@ -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");
}
}

@ -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; }
}
}

@ -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<IGuideProcessorSteps> _stepsFactory;
private IList<CustomFormatData>? _guideData;
private IGuideProcessorSteps _steps;
public GuideProcessor(ILogger log, ICustomFormatGuideParser guideParser,
Func<IGuideProcessorSteps> stepsFactory)
{
_guideParser = guideParser;
_stepsFactory = stepsFactory;
Log = log;
_steps = stepsFactory();
}
private ILogger Log { get; }
public IReadOnlyCollection<ProcessedCustomFormatData> ProcessedCustomFormats
=> _steps.CustomFormat.ProcessedCustomFormats;
public IReadOnlyCollection<string> CustomFormatsNotInGuide
=> _steps.Config.CustomFormatsNotInGuide;
public IReadOnlyCollection<ProcessedConfigData> ConfigData
=> _steps.Config.ConfigData;
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores
=> _steps.QualityProfile.ProfileScores;
public IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore
=> _steps.QualityProfile.CustomFormatsWithoutScore;
public IReadOnlyCollection<TrashIdMapping> DeletedCustomFormatsInCache
=> _steps.CustomFormat.DeletedCustomFormatsInCache;
public List<(string, string)> CustomFormatsWithOutdatedNames
=> _steps.CustomFormat.CustomFormatsWithOutdatedNames;
public async Task BuildGuideData(IReadOnlyList<CustomFormatConfig> 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();
}
}
}

@ -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<ProcessedCustomFormatData> RenamedCustomFormats { get; private set; } = new();
public List<string> CustomFormatsNotInGuide { get; } = new();
public List<ProcessedConfigData> ConfigData { get; } = new();
public void Process(IReadOnlyCollection<ProcessedCustomFormatData> processedCfs,
IEnumerable<CustomFormatConfig> 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();
}
}
}

@ -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<ProcessedCustomFormatData> ProcessedCustomFormats { get; } = new();
public List<TrashIdMapping> DeletedCustomFormatsInCache { get; } = new();
public void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> 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<string>();
var trashId = obj["trash_id"].Value<string>();
// 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))));
}
}
}

@ -0,0 +1,15 @@
using System.Collections.Generic;
using Trash.Radarr.CustomFormat.Models;
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
{
public interface IConfigStep
{
List<ProcessedCustomFormatData> RenamedCustomFormats { get; }
List<string> CustomFormatsNotInGuide { get; }
List<ProcessedConfigData> ConfigData { get; }
void Process(IReadOnlyCollection<ProcessedCustomFormatData> processedCfs,
IEnumerable<CustomFormatConfig> config);
}
}

@ -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<ProcessedCustomFormatData> ProcessedCustomFormats { get; }
List<TrashIdMapping> DeletedCustomFormatsInCache { get; }
List<(string, string)> CustomFormatsWithOutdatedNames { get; }
void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> config,
CustomFormatCache? cache);
}
}

@ -0,0 +1,12 @@
using System.Collections.Generic;
using Trash.Radarr.CustomFormat.Models;
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
{
public interface IQualityProfileStep
{
Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
void Process(IEnumerable<ProcessedConfigData> configData);
}
}

@ -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<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; } = new();
public List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } = new();
public void Process(IEnumerable<ProcessedConfigData> 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));
}
}
}
}
}

@ -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<ProcessedCustomFormatData> ProcessedCustomFormats { get; }
IReadOnlyCollection<string> CustomFormatsNotInGuide { get; }
IReadOnlyCollection<ProcessedConfigData> ConfigData { get; }
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
IReadOnlyCollection<TrashIdMapping> DeletedCustomFormatsInCache { get; }
List<(string, string)> CustomFormatsWithOutdatedNames { get; }
Task BuildGuideData(IReadOnlyList<CustomFormatConfig> config, CustomFormatCache? cache);
void Reset();
}
}

@ -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<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
IReadOnlyCollection<string> InvalidProfileNames { get; }
CustomFormatTransactionData Transactions { get; }
Task PersistCustomFormats(IReadOnlyCollection<ProcessedCustomFormatData> guideCfs,
IEnumerable<TrashIdMapping> deletedCfsInCache,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> profileScores);
void Reset();
}
}

@ -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<IPersistenceProcessorSteps> _stepsFactory;
private IPersistenceProcessorSteps _steps;
public PersistenceProcessor(
ICustomFormatService customFormatService,
IQualityProfileService qualityProfileService,
IServiceConfiguration config,
Func<IPersistenceProcessorSteps> stepsFactory)
{
_customFormatService = customFormatService;
_qualityProfileService = qualityProfileService;
_stepsFactory = stepsFactory;
_config = (RadarrConfiguration) config;
_steps = _stepsFactory();
}
public CustomFormatTransactionData Transactions
=> _steps.JsonTransactionStep.Transactions;
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores
=> _steps.ProfileQualityProfileApiPersister.UpdatedScores;
public IReadOnlyCollection<string> InvalidProfileNames
=> _steps.ProfileQualityProfileApiPersister.InvalidProfileNames;
public void Reset()
{
_steps = _stepsFactory();
}
public async Task PersistCustomFormats(IReadOnlyCollection<ProcessedCustomFormatData> guideCfs,
IEnumerable<TrashIdMapping> deletedCfsInCache,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> 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);
}
}
}

@ -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);
}
}
}
}

@ -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);
}
}

@ -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<ProcessedCustomFormatData> guideCfs,
IReadOnlyCollection<JObject> radarrCfs);
void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, List<JObject> radarrCfs);
}
}

@ -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<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
IReadOnlyCollection<string> InvalidProfileNames { get; }
Task Process(IQualityProfileService api,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> cfScores);
}
}

@ -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<ProcessedCustomFormatData> NewCustomFormats { get; } = new();
public List<ProcessedCustomFormatData> UpdatedCustomFormats { get; } = new();
public List<TrashIdMapping> DeletedCustomFormatIds { get; } = new();
public List<ProcessedCustomFormatData> UnchangedCustomFormats { get; } = new();
}
public class JsonTransactionStep : IJsonTransactionStep
{
public CustomFormatTransactionData Transactions { get; } = new();
public void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
IReadOnlyCollection<JObject> 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<TrashIdMapping> deletedCfsInCache, List<JObject> 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<JObject> radarrCfs, ProcessedCustomFormatData guideCf)
{
return FindRadarrCf(radarrCfs, guideCf.CacheEntry?.CustomFormatId, guideCf.Name);
}
private static JObject? FindRadarrCf(IReadOnlyCollection<JObject> 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<int>());
}
// 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<string>()));
}
return match;
}
private static void UpdateRadarrCf(JObject cfToModify, JObject cfToMergeFrom)
{
MergeProperties(cfToModify, cfToMergeFrom, JTokenType.Array);
var radarrSpecs = cfToModify["specifications"].Children<JObject>();
var guideSpecs = cfToMergeFrom["specifications"].Children<JObject>();
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<string>() == right[keyName].Value<string>();
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;
}
}
}

@ -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<string> _invalidProfileNames = new();
private readonly Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> _updatedScores = new();
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores => _updatedScores;
public IReadOnlyCollection<string> InvalidProfileNames => _invalidProfileNames;
public async Task Process(IQualityProfileService api,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> 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<JObject>()).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<JObject> jsonList)
{
return jsonList.FirstOrDefault(j
=> score.CustomFormat.CacheEntry != null &&
(int) j["format"] == score.CustomFormat.CacheEntry.CustomFormatId);
}
}
}

@ -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<List<RadarrQualityDefinitionItem>> GetQualityDefinition();
Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition(IList<RadarrQualityDefinitionItem> newQuality);

@ -1,6 +1,6 @@
using JetBrains.Annotations;
namespace Trash.Radarr.Api.Objects
namespace Trash.Radarr.QualityDefinition.Api.Objects
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrQualityItem

@ -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<List<RadarrQualityDefinitionItem>> GetQualityDefinition()
{
return await BaseUrl()
return await BaseUrl
.AppendPathSegment("qualitydefinition")
.GetJsonAsync<List<RadarrQualityDefinitionItem>>();
}
@ -26,15 +28,10 @@ namespace Trash.Radarr.Api
public async Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition(
IList<RadarrQualityDefinitionItem> newQuality)
{
return await BaseUrl()
return await BaseUrl
.AppendPathSegment("qualityDefinition/update")
.PutJsonAsync(newQuality)
.ReceiveJson<List<RadarrQualityDefinitionItem>>();
}
private string BaseUrl()
{
return _serviceConfig.BuildUrl();
}
}
}

@ -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;

@ -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<RadarrConfiguration> _configLoader;
private readonly Lazy<ICustomFormatUpdater> _customFormatUpdater;
private readonly Func<RadarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public RadarrCommand(
ILogger logger,
LoggingLevelSwitch loggingLevelSwitch,
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<RadarrQualityDefinitionUpdater> qualityUpdaterFactory)
Func<RadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Lazy<ICustomFormatUpdater> 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)

@ -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<CustomFormatConfig> 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<string> Names { get; set; } = new();
public List<QualityProfileConfig> 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
{

@ -14,6 +14,8 @@ namespace Trash.Sonarr.Api
Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate);
Task<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile newProfile);
Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition();
Task<IList<SonarrQualityDefinitionItem>> UpdateQualityDefinition(IReadOnlyCollection<SonarrQualityDefinitionItem> newQuality);
Task<IList<SonarrQualityDefinitionItem>> UpdateQualityDefinition(
IReadOnlyCollection<SonarrQualityDefinitionItem> newQuality);
}
}

@ -12,7 +12,6 @@ namespace Trash.Sonarr.ReleaseProfile
Preferred
}
public class ParserState
{
public ParserState(ILogger logger)

@ -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()
{

@ -6,17 +6,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" />
<PackageReference Include="Autofac.Extras.AggregateService" />
<PackageReference Include="Autofac" />
<PackageReference Include="CliFx" />
<PackageReference Include="Serilog" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Flurl" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Autofac" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="Serilog" />
<PackageReference Include="System.Data.HashFunction.FNV" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>

@ -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();
}
}
}

@ -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.

@ -8,5 +8,9 @@
&amp;lt;option name="myName" value="TrashUpdaterCleanup" /&amp;gt;&#xD;
&amp;lt;/profile&amp;gt;&lt;/IDEA_SETTINGS&gt;&lt;CSShortenReferences&gt;True&lt;/CSShortenReferences&gt;&lt;CSUpdateFileHeader&gt;True&lt;/CSUpdateFileHeader&gt;&lt;/Profile&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/SilentCleanupProfile/@EntryValue">TrashUpdaterCleanup</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=customformat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Persister/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=qualitydefinition/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=qualityprofile/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Radarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
Loading…
Cancel
Save