feat: Custom format support for Sonarr v4

pull/124/head
Robert Dailey 2 years ago
commit a124902f6d

4
.gitattributes vendored

@ -1,4 +1,8 @@
* text=auto
# Files that require LF line endings
*.sh eol=lf
Dockerfile eol=lf
# Ignore whitespace in these files
*.sln -whitespace

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Settings: New `log_janitor` setting that allows you to specify how many log files are kept when
cleaning up (deleting) old log files. See the [Settings Reference] wiki page for more details.
(#91)
- Sonarr: Custom Formats can now be synced to Version 4.
### Fixed

@ -6,60 +6,37 @@ networks:
volumes:
radarr_nightly:
radarr_latest:
sonarr_nightly:
sonarr_latest:
sonarr_v4:
services:
radarr_latest:
image: ghcr.io/hotio/radarr
networks:
recyclarr:
aliases:
- radarr_latest
ports:
- 7879:7878
volumes:
- radarr_latest:/config
environment:
- TZ=America/Chicago
radarr_nightly:
image: ghcr.io/hotio/radarr:nightly
networks:
recyclarr:
aliases:
- radarr_nightly
ports:
- 7878:7878
container_name: radarr_nightly
networks: [recyclarr]
ports: [7878:7878]
volumes:
- radarr_nightly:/config
- ./certs:/certs:ro
environment:
- TZ=America/Chicago
sonarr_latest:
image: ghcr.io/hotio/sonarr
networks:
recyclarr:
aliases:
- sonarr_latest
ports:
- 8990:8989
sonarr_nightly:
image: ghcr.io/hotio/sonarr:nightly
container_name: sonarr_nightly
networks: [recyclarr]
ports: [8989:8989]
volumes:
- sonarr_latest:/config
- sonarr_nightly:/config
environment:
- TZ=America/Chicago
sonarr_nightly:
image: ghcr.io/hotio/sonarr:nightly
networks:
recyclarr:
aliases:
- sonarr_nightly
ports:
- 8989:8989
sonarr_v4:
image: ghcr.io/hotio/sonarr:v4
container_name: sonarr_v4
networks: [recyclarr]
ports: [8990:8989]
volumes:
- sonarr_nightly:/config
- sonarr_v4:/config
environment:
- TZ=America/Chicago

@ -28,14 +28,24 @@
"quality_definition": {
"type": "string"
},
"delete_old_custom_formats": {
"$ref": "#/$defs/delete_old_custom_formats"
},
"custom_formats": {
"$ref": "#/$defs/custom_formats"
},
"release_profiles": {
"type": "array",
"minItems": 1,
"items": {
"additionalProperties": false,
"anyOf": [
{"required": ["trash_ids"]},
{"required": ["names"]}
{
"required": ["trash_ids"]
},
{
"required": ["names"]
}
],
"properties": {
"trash_ids": {
@ -58,8 +68,12 @@
"additionalProperties": false,
"description": "Defines various ways that release profile terms from the guide are synchronized with Sonarr.",
"oneOf": [
{"required": ["include"]},
{"required": ["exclude"]}
{
"required": ["include"]
},
{
"required": ["exclude"]
}
],
"properties": {
"include": {
@ -116,58 +130,10 @@
}
},
"delete_old_custom_formats": {
"type": "boolean",
"description": "If enabled, custom formats that you remove from your YAML configuration OR that are removed from the guide will be deleted from your Radarr instance.",
"default": false
"$ref": "#/$defs/delete_old_custom_formats"
},
"custom_formats": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"description": "A list of one or more sets of custom formats each with an optional set of quality profiles names that identify which quality profiles to assign the scores for those custom formats to.",
"anyOf": [
{"required": ["trash_ids"]},
{"required": ["names"]}
],
"properties": {
"names": {
"type": "array",
"uniqueItems": true,
"description": "A list of one or more custom format names to synchronize to Radarr. The names must be taken from the JSON itself in the guide.",
"minItems": 1,
"items": {
"type": "string"
}
},
"trash_ids": {
"$ref": "#/$defs/trash_ids_list"
},
"quality_profiles": {
"type": "array",
"description": "One or more quality profiles to update with the scores from the specified custom formats.",
"minItems": 1,
"items": {
"properties": {
"name": {
"type": "string",
"description": "The name of one of the quality profiles in Radarr."
},
"score": {
"type": "integer",
"description": "A positive or negative number representing the score to apply to *all* custom formats listed in the names list."
},
"reset_unmatched_scores": {
"type": "boolean",
"description": "If set to true, enables setting scores to 0 in quality profiles where either a name was not mentioned in the names array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false
}
}
}
}
}
}
"$ref": "#/$defs/custom_formats"
}
}
}
@ -192,6 +158,64 @@
"type": "string",
"pattern": "^https?",
"description": "The base URL of your instance. Basically this is the URL you bookmark to get to the front page."
},
"delete_old_custom_formats": {
"type": "boolean",
"description": "If enabled, custom formats that you remove from your YAML configuration OR that are removed from the guide will be deleted from your Radarr instance.",
"default": false
},
"custom_formats": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"description": "A list of one or more sets of custom formats each with an optional set of quality profiles names that identify which quality profiles to assign the scores for those custom formats to.",
"anyOf": [
{
"required": ["trash_ids"]
},
{
"required": ["names"]
}
],
"properties": {
"names": {
"type": "array",
"uniqueItems": true,
"description": "A list of one or more custom format names to synchronize to Radarr. The names must be taken from the JSON itself in the guide.",
"minItems": 1,
"items": {
"type": "string"
}
},
"trash_ids": {
"$ref": "#/$defs/trash_ids_list"
},
"quality_profiles": {
"type": "array",
"description": "One or more quality profiles to update with the scores from the specified custom formats.",
"minItems": 1,
"items": {
"properties": {
"name": {
"type": "string",
"description": "The name of one of the quality profiles in Radarr."
},
"score": {
"type": "integer",
"description": "A positive or negative number representing the score to apply to *all* custom formats listed in the names list."
},
"reset_unmatched_scores": {
"type": "boolean",
"description": "If set to true, enables setting scores to 0 in quality profiles where either a name was not mentioned in the names array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false
}
}
}
}
}
}
}
}
}

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
</Project>

@ -0,0 +1,32 @@
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Reflection;
namespace Common.TestLibrary;
public static class CommonMockFileSystemExtensions
{
public static void AddFileFromResource(this MockFileSystem fs, string resourceFilename)
{
fs.AddFileFromResource(resourceFilename, resourceFilename, Assembly.GetCallingAssembly());
}
public static void AddFileFromResource(this MockFileSystem fs, IFileInfo file, string resourceFilename,
string resourceDir = "Data")
{
fs.AddFileFromResource(file.FullName, resourceFilename, Assembly.GetCallingAssembly(), resourceDir);
}
public static void AddFileFromResource(this MockFileSystem fs, string file, string resourceFilename,
string resourceDir = "Data")
{
fs.AddFileFromResource(file, resourceFilename, Assembly.GetCallingAssembly(), resourceDir);
}
public static void AddFileFromResource(this MockFileSystem fs, string file, string resourceFilename,
Assembly assembly, string resourceDir = "Data")
{
var resourceReader = new ResourceDataReader(assembly, resourceDir);
fs.AddFile(file, new MockFileData(resourceReader.ReadData(resourceFilename)));
}
}

@ -9,6 +9,12 @@ public class ResourceDataReader
private readonly string? _namespace;
private readonly string _subdirectory;
public ResourceDataReader(Assembly assembly, string subdirectory = "")
{
_subdirectory = subdirectory;
_assembly = assembly;
}
public ResourceDataReader(Type typeWithNamespaceToUse, string subdirectory = "")
{
_subdirectory = subdirectory;
@ -17,21 +23,48 @@ public class ResourceDataReader
}
public string ReadData(string filename)
{
var resourcePath = BuildResourceName(filename);
var foundResource = FindResourcePath(resourcePath);
return GetResourceData(foundResource);
}
private string BuildResourceName(string filename)
{
var nameBuilder = new StringBuilder();
nameBuilder.Append(_namespace);
if (!string.IsNullOrEmpty(_namespace))
{
nameBuilder.Append($"{_namespace}.");
}
if (!string.IsNullOrEmpty(_subdirectory))
{
nameBuilder.Append($".{_subdirectory}");
nameBuilder.Append($"{_subdirectory}.");
}
nameBuilder.Append($".{filename}");
nameBuilder.Append(filename);
return nameBuilder.ToString();
}
var resourceName = nameBuilder.ToString();
using var stream = _assembly?.GetManifestResourceStream(resourceName);
if (stream == null)
private string FindResourcePath(string resourcePath)
{
var foundResource = _assembly?.GetManifestResourceNames()
.FirstOrDefault(x => x.EndsWith(resourcePath));
if (foundResource is null)
{
throw new ArgumentException($"Embedded resource not found: {resourcePath}");
}
return foundResource;
}
private string GetResourceData(string resourcePath)
{
using var stream = _assembly?.GetManifestResourceStream(resourcePath);
if (stream is null)
{
throw new ArgumentException($"Embedded resource not found: {resourceName}");
throw new ArgumentException($"Unable to open embedded resource: {resourcePath}");
}
using var reader = new StreamReader(stream);

@ -37,10 +37,7 @@
<PackageReference Include="GitVersion.MsBuild" PrivateAssets="All" />
</ItemGroup>
<PropertyGroup Condition="$(ProjectName.EndsWith('.Tests'))">
</PropertyGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests')) Or $(ProjectName.EndsWith('.TestLibrary'))">
<PackageReference Include="AutofacContrib.NSubstitute" PrivateAssets="All" />
<PackageReference Include="AutoFixture" PrivateAssets="All" />
<PackageReference Include="AutoFixture.AutoNSubstitute" PrivateAssets="All" />
@ -61,7 +58,7 @@
<PackageReference Include="TestableIO.System.IO.Abstractions.Extensions" PrivateAssets="All" />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests')) Or $(ProjectName.EndsWith('.TestLibrary'))">
<EmbeddedResource Include="**\Data\*" />
</ItemGroup>
</Project>

@ -0,0 +1,13 @@
{
"json_paths": {
"radarr": {
"custom_formats": ["docs/json/radarr/cf"],
"qualities": ["docs/json/radarr/quality-size"]
},
"sonarr": {
"release_profiles": ["docs/json/sonarr/rp"],
"custom_formats": ["docs/json/sonarr/cf"],
"qualities": ["docs/json/sonarr/quality-size"]
}
}
}

@ -3,8 +3,13 @@ using System.IO.Abstractions.TestingHelpers;
using Autofac;
using Autofac.Features.ResolveAnything;
using CliFx.Infrastructure;
using Common.TestLibrary;
using NSubstitute;
using NUnit.Framework;
using Serilog.Events;
using TrashLib.Startup;
using VersionControl;
using VersionControl.Wrappers;
namespace Recyclarr.TestLibrary;
@ -12,20 +17,38 @@ namespace Recyclarr.TestLibrary;
public abstract class IntegrationFixture : IDisposable
{
private readonly ILifetimeScope _container;
private readonly FakeConsole _console = new();
protected IntegrationFixture()
{
var compRoot = new CompositionRoot();
_container = compRoot.Setup(default, _console, LogEventLevel.Debug).Container
_container = compRoot.Setup(default, Console, LogEventLevel.Debug).Container
.BeginLifetimeScope(builder =>
{
builder.RegisterSource<AnyConcreteTypeNotAlreadyRegisteredSource>();
builder.RegisterInstance(Fs).As<IFileSystem>();
RegisterMockFor<IGitRepository>(builder);
RegisterMockFor<IGitRepositoryFactory>(builder);
RegisterMockFor<IRepositoryStaticWrapper>(builder);
});
SetupMetadataJson();
}
private void SetupMetadataJson()
{
var paths = Resolve<IAppPaths>();
var metadataFile = paths.RepoDirectory.File("metadata.json");
Fs.AddFileFromResource(metadataFile, "metadata.json");
}
protected MockFileSystem Fs { get; } = new();
protected FakeInMemoryConsole Console { get; } = new();
private static void RegisterMockFor<T>(ContainerBuilder builder) where T : class
{
builder.RegisterInstance(Substitute.For<T>()).As<T>();
}
protected T Resolve<T>(Action<ContainerBuilder> customRegistrations) where T : notnull
{
@ -46,7 +69,7 @@ public abstract class IntegrationFixture : IDisposable
}
_container.Dispose();
_console.Dispose();
Console.Dispose();
}
public void Dispose()

@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Common.TestLibrary\Common.TestLibrary.csproj" />
<ProjectReference Include="..\Recyclarr\Recyclarr.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" PrivateAssets="All" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
</ItemGroup>
</Project>

@ -50,6 +50,8 @@ public class ConfigurationLoaderTest
{
public string BaseUrl => "";
public string ApiKey => "";
public ICollection<CustomFormatConfig> CustomFormats => new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats => false;
}
[Test, AutoMockData(typeof(ConfigurationLoaderTest), nameof(BuildContainer))]

@ -12,7 +12,7 @@ namespace Recyclarr.Tests;
public class ServiceCompatibilityIntegrationTest : IntegrationFixture
{
[Test]
public void Load_data_correctly_when_file_exists()
public void Load_settings_yml_correctly_when_file_exists()
{
var sut = Resolve<ISettingsProvider>();
var paths = Resolve<IAppPaths>();

@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.TestLibrary", "Re
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Gui", "Recyclarr.Gui\Recyclarr.Gui.csproj", "{53EECBC0-E0EA-4D6C-925C-5DB8C42CCB85}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.TestLibrary", "Common.TestLibrary\Common.TestLibrary.csproj", "{A92321B5-2796-467B-B5A5-2BFC41167A25}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -91,6 +93,10 @@ Global
{53EECBC0-E0EA-4D6C-925C-5DB8C42CCB85}.Debug|Any CPU.Build.0 = Debug|Any CPU
{53EECBC0-E0EA-4D6C-925C-5DB8C42CCB85}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53EECBC0-E0EA-4D6C-925C-5DB8C42CCB85}.Release|Any CPU.Build.0 = Release|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection

@ -3,8 +3,9 @@ using JetBrains.Annotations;
using Recyclarr.Config;
using Serilog;
using TrashLib.Extensions;
using TrashLib.Services.CustomFormat;
using TrashLib.Services.Radarr;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat;
using TrashLib.Services.Radarr.QualityDefinition;
namespace Recyclarr.Command;
@ -32,6 +33,7 @@ internal class RadarrCommand : ServiceCommand
var customFormatUpdaterFactory = container.Resolve<Func<ICustomFormatUpdater>>();
var qualityUpdaterFactory = container.Resolve<Func<IRadarrQualityDefinitionUpdater>>();
var configLoader = container.Resolve<IConfigurationLoader<RadarrConfiguration>>();
var guideService = container.Resolve<IRadarrGuideService>();
if (ListCustomFormats)
{
@ -56,7 +58,7 @@ internal class RadarrCommand : ServiceCommand
if (config.CustomFormats.Count > 0)
{
await customFormatUpdaterFactory().Process(Preview, config);
await customFormatUpdaterFactory().Process(Preview, config.CustomFormats, guideService);
}
}
}

@ -4,10 +4,12 @@ using JetBrains.Annotations;
using Recyclarr.Config;
using Serilog;
using TrashLib.Extensions;
using TrashLib.Services.CustomFormat;
using TrashLib.Services.Sonarr;
using TrashLib.Services.Sonarr.Config;
using TrashLib.Services.Sonarr.QualityDefinition;
using TrashLib.Services.Sonarr.ReleaseProfile;
using TrashLib.Services.Sonarr.ReleaseProfile.Guide;
namespace Recyclarr.Command;
@ -30,6 +32,10 @@ public class SonarrCommand : ServiceCommand
"List available quality definition types from the guide.")]
public bool ListQualities { get; [UsedImplicitly] set; }
[CommandOption("list-custom-formats", Description =
"List available custom formats from the guide in YAML format.")]
public bool ListCustomFormats { get; [UsedImplicitly] set; }
public override string Name => "Sonarr";
public override async Task Process(IServiceLocatorProxy container)
@ -41,6 +47,8 @@ public class SonarrCommand : ServiceCommand
var qualityUpdaterFactory = container.Resolve<Func<ISonarrQualityDefinitionUpdater>>();
var configLoader = container.Resolve<IConfigurationLoader<SonarrConfiguration>>();
var log = container.Resolve<ILogger>();
var customFormatUpdaterFactory = container.Resolve<Func<ICustomFormatUpdater>>();
var guideService = container.Resolve<ISonarrGuideService>();
if (ListReleaseProfiles)
{
@ -54,6 +62,12 @@ public class SonarrCommand : ServiceCommand
return;
}
if (ListCustomFormats)
{
lister.ListCustomFormats();
return;
}
if (ListTerms != "empty")
{
if (!string.IsNullOrEmpty(ListTerms))
@ -82,6 +96,11 @@ public class SonarrCommand : ServiceCommand
{
await qualityUpdaterFactory().Process(Preview, config);
}
if (config.CustomFormats.Count > 0)
{
await customFormatUpdaterFactory().Process(Preview, config.CustomFormats, guideService);
}
}
}
}

@ -16,7 +16,10 @@ using Serilog.Events;
using TrashLib;
using TrashLib.Cache;
using TrashLib.Config;
using TrashLib.Config.Services;
using TrashLib.Repo;
using TrashLib.Services.Common;
using TrashLib.Services.CustomFormat;
using TrashLib.Services.Radarr;
using TrashLib.Services.Sonarr;
using TrashLib.Startup;
@ -45,12 +48,15 @@ public class CompositionRoot : ICompositionRoot
builder.RegisterModule<VersionControlAutofacModule>();
builder.RegisterModule<MigrationAutofacModule>();
builder.RegisterModule<RepoAutofacModule>();
builder.RegisterModule<CustomFormatAutofacModule>();
builder.RegisterModule<GuideServicesAutofacModule>();
// Needed for Autofac.Extras.Ordering
builder.RegisterSource<OrderedRegistrationSource>();
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<ServerInfo>().As<IServerInfo>();
builder.RegisterType<ProgressBar>();
ConfigurationRegistrations(builder);

@ -1,4 +1,4 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.TestLibrary;

@ -1,6 +1,6 @@
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.TestLibrary;

@ -19,7 +19,7 @@ public class SettingsPersisterTest
[Frozen] IAppPaths paths,
SettingsProvider sut)
{
var settings = sut.Settings;
_ = sut.Settings;
fileSystem.AllFiles.Should().ContainSingle(paths.SettingsPath.FullName);
}

@ -5,12 +5,12 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using TrashLib.Cache;
using TrashLib.Services.Radarr.CustomFormat;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat;
namespace TrashLib.Tests.CustomFormat;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -4,10 +4,10 @@ using AutoFixture.NUnit3;
using FluentAssertions;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Startup;
namespace TrashLib.Tests.Radarr.CustomFormat.Guide;
namespace TrashLib.Tests.CustomFormat.Guide;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -0,0 +1,55 @@
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TestLibrary.FluentAssertions;
using TrashLib.Services.CustomFormat.Guide;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.CustomFormat.Guide;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CustomFormatLoaderTest : IntegrationFixture
{
[Test]
public void Get_custom_format_json_works()
{
var sut = Resolve<ICustomFormatLoader>();
Fs.AddFile("first.json", new MockFileData("{'name':'first','trash_id':'1'}"));
Fs.AddFile("second.json", new MockFileData("{'name':'second','trash_id':'2'}"));
var results = sut.LoadAllCustomFormatsAtPaths(new[] {Fs.CurrentDirectory()});
results.Should().BeEquivalentTo(new[]
{
NewCf.Data("first", "1"),
NewCf.Data("second", "2")
});
}
[Test]
public void Trash_properties_are_removed()
{
Fs.AddFile("first.json", new MockFileData(@"
{
'name':'first',
'trash_id':'1',
'trash_foo': 'foo',
'trash_bar': 'bar',
'extra': 'e1'
}"));
var sut = Resolve<ICustomFormatLoader>();
var results = sut.LoadAllCustomFormatsAtPaths(new[] {Fs.CurrentDirectory()});
const string expectedExtraJson = @"{'name':'first','extra': 'e1'}";
results.Should()
.ContainSingle().Which.ExtraJson.Should()
.BeEquivalentTo(JObject.Parse(expectedExtraJson), op => op.Using(new JsonEquivalencyStep()));
}
}

@ -1,24 +1,21 @@
using AutoFixture.NUnit3;
using CliFx.Infrastructure;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Services.Radarr.CustomFormat;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Services.Common;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat;
namespace TrashLib.Tests.CustomFormat;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrGuideDataListerTest
public class GuideDataListerTest
{
[Test, AutoMockData]
public void Custom_formats_appear_in_console_output(
[Frozen] IRadarrGuideService guide,
[Frozen(Matching.ImplementedInterfaces)] FakeInMemoryConsole console,
RadarrGuideDataLister sut)
GuideDataLister sut)
{
var testData = new[]
{
@ -26,9 +23,7 @@ public class RadarrGuideDataListerTest
NewCf.Data("Second", "456")
};
guide.GetCustomFormatData().Returns(testData);
sut.ListCustomFormats();
sut.ListCustomFormats(testData);
console.ReadOutputString().Should().ContainAll(
testData.SelectMany(x => new[] {x.Name, x.TrashId}));

@ -6,14 +6,15 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using TestLibrary.FluentAssertions;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Processors;
using TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Processors;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
using TrashLib.Services.Radarr;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
namespace TrashLib.Tests.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
@ -35,8 +36,11 @@ public class GuideProcessorTest
public ResourceDataReader Data { get; }
public CustomFormatData ReadCustomFormat(string textFile) =>
LocalRepoRadarrGuideService.ParseCustomFormatData(ReadText(textFile));
public CustomFormatData ReadCustomFormat(string textFile)
{
var parser = new CustomFormatParser();
return parser.ParseCustomFormatData(ReadText(textFile));
}
public string ReadText(string textFile) => Data.ReadData(textFile);
public JObject ReadJson(string jsonFile) => JObject.Parse(ReadText(jsonFile));
@ -48,7 +52,7 @@ public class GuideProcessorTest
{
var ctx = new Context();
var guideService = Substitute.For<IRadarrGuideService>();
var guideProcessor = new GuideProcessor(guideService, () => new TestGuideProcessorSteps());
var guideProcessor = new GuideProcessor(() => new TestGuideProcessorSteps());
// simulate guide data
guideService.GetCustomFormatData().Returns(new[]
@ -82,7 +86,7 @@ public class GuideProcessorTest
}
};
await guideProcessor.BuildGuideDataAsync(config, null);
await guideProcessor.BuildGuideDataAsync(config, null, guideService);
var expectedProcessedCustomFormatData = new List<ProcessedCustomFormatData>
{

@ -2,13 +2,13 @@ using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -4,13 +4,13 @@ using Newtonsoft.Json.Linq;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TestLibrary.FluentAssertions;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -1,11 +1,11 @@
using FluentAssertions;
using NUnit.Framework;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -3,13 +3,13 @@ using Newtonsoft.Json.Linq;
using NSubstitute;
using NUnit.Framework;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
namespace TrashLib.Tests.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -1,12 +1,12 @@
using NSubstitute;
using NUnit.Framework;
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -3,9 +3,9 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using TestLibrary.FluentAssertions;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
using TrashLib.TestLibrary;
/* Sample Custom Format response from Radarr API
@ -36,7 +36,7 @@ using TrashLib.TestLibrary;
}
*/
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -5,13 +5,13 @@ using Newtonsoft.Json.Linq;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.NSubstitute;
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -1,78 +0,0 @@
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TestLibrary.FluentAssertions;
using TrashLib.Repo;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Startup;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Radarr.CustomFormat.Guide;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class LocalRepoRadarrGuideServiceTest
{
[Test, AutoMockData]
public void Get_custom_format_json_works(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths appPaths,
[Frozen] IRepoPaths repoPaths,
LocalRepoRadarrGuideService sut)
{
var jsonDir = appPaths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("radarr");
fs.AddFile(jsonDir.File("first.json").FullName, new MockFileData("{'name':'first','trash_id':'1'}"));
fs.AddFile(jsonDir.File("second.json").FullName, new MockFileData("{'name':'second','trash_id':'2'}"));
repoPaths.RadarrCustomFormatPaths.Returns(new[] {jsonDir});
var results = sut.GetCustomFormatData();
results.Should().BeEquivalentTo(new[]
{
NewCf.Data("first", "1"),
NewCf.Data("second", "2")
});
}
[Test, AutoMockData]
public void Trash_properties_are_removed(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths appPaths,
[Frozen] IRepoPaths repoPaths,
LocalRepoRadarrGuideService sut)
{
var jsonDir = appPaths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("radarr");
fs.AddFile(jsonDir.File("first.json").FullName, new MockFileData(@"
{
'name':'first',
'trash_id':'1',
'trash_foo': 'foo',
'trash_bar': 'bar',
'extra': 'e1'
}"));
repoPaths.RadarrCustomFormatPaths.Returns(new[] {jsonDir});
var results = sut.GetCustomFormatData();
const string expectedExtraJson = @"{'name':'first','extra': 'e1'}";
results.Should()
.ContainSingle().Which.ExtraJson.Should()
.BeEquivalentTo(JObject.Parse(expectedExtraJson), op => op.Using(new JsonEquivalencyStep()));
}
}

@ -4,6 +4,7 @@ using FluentAssertions;
using FluentValidation;
using NUnit.Framework;
using TrashLib.Config;
using TrashLib.Config.Services;
using TrashLib.Services.Radarr;
using TrashLib.Services.Radarr.Config;

@ -8,6 +8,7 @@ using Newtonsoft.Json.Serialization;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Config.Services;
using TrashLib.ExceptionTypes;
using TrashLib.Services.Sonarr;
using TrashLib.Services.Sonarr.Api;
@ -152,4 +153,40 @@ public class SonarrCompatibilityTest
await act.Should().NotThrowAsync();
}
[Test, AutoMockData]
public async Task Failure_when_custom_formats_used_with_sonarr_v3(
[Frozen] ISonarrApi api,
[Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility,
ReleaseProfileUpdater updater)
{
api.GetVersion().Returns(new Version(3, 9));
var config = new SonarrConfiguration
{
CustomFormats = new List<CustomFormatConfig> {new()}
};
var act = () => updater.Process(false, config);
await act.Should().ThrowAsync<VersionException>().WithMessage("Sonarr v3*custom format*use*v4*");
}
[Test, AutoMockData]
public async Task No_failure_when_custom_formats_used_with_sonarr_v4(
[Frozen] ISonarrApi api,
[Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility,
ReleaseProfileUpdater updater)
{
api.GetVersion().Returns(new Version(4, 0));
var config = new SonarrConfiguration
{
CustomFormats = new List<CustomFormatConfig> {new()}
};
var act = () => updater.Process(false, config);
await act.Should().NotThrowAsync();
}
}

@ -5,4 +5,5 @@ namespace TrashLib.Config.Services;
public interface IServerInfo
{
Url BuildRequest();
string SanitizedBaseUrl { get; }
}

@ -4,4 +4,6 @@ public interface IServiceConfiguration
{
string BaseUrl { get; }
string ApiKey { get; }
ICollection<CustomFormatConfig> CustomFormats { get; }
bool DeleteOldCustomFormats { get; }
}

@ -1,8 +1,9 @@
using Flurl;
using TrashLib.Extensions;
namespace TrashLib.Config.Services;
internal class ServerInfo : IServerInfo
public class ServerInfo : IServerInfo
{
private readonly IConfigurationProvider _config;
@ -20,4 +21,6 @@ internal class ServerInfo : IServerInfo
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = apiKey});
}
public string SanitizedBaseUrl => FlurlLogging.SanitizeUrl(_config.ActiveConfiguration.BaseUrl);
}

@ -1,7 +1,27 @@
using JetBrains.Annotations;
namespace TrashLib.Config.Services;
public abstract class ServiceConfiguration : IServiceConfiguration
{
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
public ICollection<CustomFormatConfig> CustomFormats { get; init; } = new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class CustomFormatConfig
{
public ICollection<string> Names { get; init; } = new List<string>();
public ICollection<string> TrashIds { get; init; } = new List<string>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>();
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig
{
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
}

@ -8,4 +8,5 @@ public interface IRepoPaths
IReadOnlyCollection<IDirectoryInfo> SonarrReleaseProfilePaths { get; }
IReadOnlyCollection<IDirectoryInfo> SonarrQualityPaths { get; }
IReadOnlyCollection<IDirectoryInfo> RadarrQualityPaths { get; }
IReadOnlyCollection<IDirectoryInfo> SonarrCustomFormatPaths { get; }
}

@ -7,7 +7,8 @@ public record RadarrMetadata(
public record SonarrMetadata(
IReadOnlyCollection<string> ReleaseProfiles,
IReadOnlyCollection<string> Qualities
IReadOnlyCollection<string> Qualities,
IReadOnlyCollection<string> CustomFormats
);
public record JsonPaths(

@ -6,5 +6,6 @@ public record RepoPaths(
IReadOnlyCollection<IDirectoryInfo> RadarrCustomFormatPaths,
IReadOnlyCollection<IDirectoryInfo> SonarrReleaseProfilePaths,
IReadOnlyCollection<IDirectoryInfo> RadarrQualityPaths,
IReadOnlyCollection<IDirectoryInfo> SonarrQualityPaths
IReadOnlyCollection<IDirectoryInfo> SonarrQualityPaths,
IReadOnlyCollection<IDirectoryInfo> SonarrCustomFormatPaths
) : IRepoPaths;

@ -29,7 +29,8 @@ public class RepoPathsFactory : IRepoPathsFactory
ToDirectoryInfoList(metadata.JsonPaths.Radarr.CustomFormats),
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.ReleaseProfiles),
ToDirectoryInfoList(metadata.JsonPaths.Radarr.Qualities),
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.Qualities)
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.Qualities),
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.CustomFormats)
);
}
}

@ -0,0 +1,28 @@
using CliFx.Infrastructure;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Common;
public class GuideDataLister : IGuideDataLister
{
private readonly IConsole _console;
public GuideDataLister(IConsole console)
{
_console = console;
}
public void ListCustomFormats(IEnumerable<CustomFormatData> customFormats)
{
_console.Output.WriteLine("\nList of Custom Formats in the TRaSH Guides:\n");
foreach (var cf in customFormats)
{
_console.Output.WriteLine($" - {cf.TrashId} # {cf.Name}");
}
_console.Output.WriteLine(
"\nThe above Custom Formats are in YAML format and ready to be copied & pasted " +
"under the `trash_ids:` property.");
}
}

@ -0,0 +1,12 @@
using Autofac;
namespace TrashLib.Services.Common;
public class GuideServicesAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<GuideDataLister>().As<IGuideDataLister>();
}
}

@ -0,0 +1,8 @@
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Common;
public interface IGuideDataLister
{
void ListCustomFormats(IEnumerable<CustomFormatData> customFormats);
}

@ -0,0 +1,8 @@
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Common;
public interface IGuideService
{
ICollection<CustomFormatData> GetCustomFormatData();
}

@ -2,9 +2,9 @@ using Flurl;
using Flurl.Http;
using Newtonsoft.Json.Linq;
using TrashLib.Config.Services;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Api;
namespace TrashLib.Services.CustomFormat.Api;
internal class CustomFormatService : ICustomFormatService
{

@ -1,7 +1,7 @@
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Api;
namespace TrashLib.Services.CustomFormat.Api;
public interface ICustomFormatService
{

@ -1,6 +1,6 @@
using Newtonsoft.Json.Linq;
namespace TrashLib.Services.Radarr.CustomFormat.Api;
namespace TrashLib.Services.CustomFormat.Api;
public interface IQualityProfileService
{

@ -3,7 +3,7 @@ using Flurl.Http;
using Newtonsoft.Json.Linq;
using TrashLib.Config.Services;
namespace TrashLib.Services.Radarr.CustomFormat.Api;
namespace TrashLib.Services.CustomFormat.Api;
internal class QualityProfileService : IQualityProfileService
{

@ -1,10 +1,10 @@
using Common.Extensions;
using Serilog;
using TrashLib.Cache;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat;
namespace TrashLib.Services.CustomFormat;
internal class CachePersister : ICachePersister
{

@ -0,0 +1,34 @@
using Autofac;
using Autofac.Extras.AggregateService;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Processors;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat;
public class CustomFormatAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<CustomFormatService>().As<ICustomFormatService>();
builder.RegisterType<QualityProfileService>().As<IQualityProfileService>();
builder.RegisterType<CustomFormatUpdater>().As<ICustomFormatUpdater>();
builder.RegisterType<CachePersister>().As<ICachePersister>();
builder.RegisterType<GuideProcessor>().As<IGuideProcessor>();
builder.RegisterType<CustomFormatLoader>().As<ICustomFormatLoader>();
builder.RegisterType<CustomFormatParser>().As<ICustomFormatParser>();
builder.RegisterAggregateService<IGuideProcessorSteps>();
builder.RegisterType<CustomFormatStep>().As<ICustomFormatStep>();
builder.RegisterType<ConfigStep>().As<IConfigStep>();
builder.RegisterType<QualityProfileStep>().As<IQualityProfileStep>();
builder.RegisterType<PersistenceProcessor>().As<IPersistenceProcessor>();
builder.RegisterAggregateService<IPersistenceProcessorSteps>();
builder.RegisterType<JsonTransactionStep>().As<IJsonTransactionStep>();
builder.RegisterType<CustomFormatApiPersistenceStep>().As<ICustomFormatApiPersistenceStep>();
builder.RegisterType<QualityProfileApiPersistenceStep>().As<IQualityProfileApiPersistenceStep>();
}
}

@ -1,12 +1,12 @@
using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Extensions;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Processors;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Config.Services;
using TrashLib.Services.Common;
using TrashLib.Services.CustomFormat.Processors;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.Radarr.CustomFormat;
namespace TrashLib.Services.CustomFormat;
internal class CustomFormatUpdater : ICustomFormatUpdater
{
@ -14,30 +14,33 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
private readonly IGuideProcessor _guideProcessor;
private readonly IPersistenceProcessor _persistenceProcessor;
private readonly IConsole _console;
private readonly IServerInfo _serverInfo;
public CustomFormatUpdater(
ILogger log,
ICachePersister cache,
IGuideProcessor guideProcessor,
IPersistenceProcessor persistenceProcessor,
IConsole console)
IConsole console,
IServerInfo serverInfo)
{
Log = log;
_cache = cache;
_guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor;
_console = console;
_serverInfo = serverInfo;
}
private ILogger Log { get; }
public async Task Process(bool isPreview, RadarrConfiguration config)
public async Task Process(bool isPreview, IEnumerable<CustomFormatConfig> customFormats, IGuideService guideService)
{
_cache.Load();
await _guideProcessor.BuildGuideDataAsync(config.CustomFormats.AsReadOnly(), _cache.CfCache);
await _guideProcessor.BuildGuideDataAsync(customFormats, _cache.CfCache, guideService);
if (!ValidateGuideDataAndCheckShouldProceed(config))
if (!ValidateGuideDataAndCheckShouldProceed())
{
return;
}
@ -127,7 +130,7 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
var totalCount = created.Count + updated.Count;
if (totalCount > 0)
{
Log.Information("Total of {Count} custom formats synced to Radarr", totalCount);
Log.Information("Total of {Count} custom formats were synced", totalCount);
}
else
{
@ -135,7 +138,7 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
}
}
private bool ValidateGuideDataAndCheckShouldProceed(RadarrConfiguration config)
private bool ValidateGuideDataAndCheckShouldProceed()
{
_console.Output.WriteLine("");
@ -186,14 +189,14 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
if (_guideProcessor.ConfigData.Count == 0)
{
Log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}",
FlurlLogging.SanitizeUrl(config.BaseUrl));
_serverInfo.SanitizedBaseUrl);
return false;
}
if (_guideProcessor.CustomFormatsWithoutScore.Count > 0)
{
Log.Information("The below custom formats have no score in the guide or in your YAML config. They will " +
"still be synced to Radarr, but no score will be set in your chosen quality profiles");
"still be synced, but no score will be set in your chosen quality profiles");
foreach (var tuple in _guideProcessor.CustomFormatsWithoutScore)
{
Log.Information("{CfList}", tuple);

@ -4,7 +4,7 @@ using System.Text.RegularExpressions;
using Common.Extensions;
using TrashLib.Startup;
namespace TrashLib.Services.Radarr.CustomFormat.Guide;
namespace TrashLib.Services.CustomFormat.Guide;
public record CustomFormatGroupItem(string Name, string Anchor);

@ -0,0 +1,44 @@
using System.IO.Abstractions;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Common.Extensions;
using Newtonsoft.Json;
using Serilog;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Guide;
public class CustomFormatLoader : ICustomFormatLoader
{
private readonly ILogger _log;
private readonly ICustomFormatParser _parser;
public CustomFormatLoader(ILogger log, ICustomFormatParser parser)
{
_log = log;
_parser = parser;
}
public ICollection<CustomFormatData> LoadAllCustomFormatsAtPaths(IEnumerable<IDirectoryInfo> jsonPaths)
{
var jsonFiles = jsonPaths.SelectMany(x => x.GetFiles("*.json"));
return jsonFiles.ToObservable()
.Select(x => Observable.Defer(() => LoadJsonFromFile(x)))
.Merge(8)
.NotNull()
.ToEnumerable()
.ToList();
}
private IObservable<CustomFormatData?> LoadJsonFromFile(IFileInfo file)
{
return Observable.Using(file.OpenText, x => x.ReadToEndAsync().ToObservable())
.Do(_ => _log.Debug("Parsing CF Json: {Name}", file.Name))
.Select(_parser.ParseCustomFormatData)
.Catch((JsonException e) =>
{
_log.Warning("Failed to parse JSON file: {File} ({Reason})", file.Name, e.Message);
return Observable.Empty<CustomFormatData>();
});
}
}

@ -0,0 +1,34 @@
using System.Text.RegularExpressions;
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Guide;
public class CustomFormatParser : ICustomFormatParser
{
public CustomFormatData ParseCustomFormatData(string guideData)
{
var obj = JObject.Parse(guideData);
var name = obj.ValueOrThrow<string>("name");
var trashId = obj.ValueOrThrow<string>("trash_id");
int? finalScore = null;
if (obj.TryGetValue("trash_score", out var score))
{
finalScore = (int) score;
}
// Remove any properties starting with "trash_". Those are metadata that are not meant for the remote service
// itself. The service supposedly drops this anyway, but I prefer it to be removed. ToList() is important here
// since removing the property itself modifies the collection, and we don't want the collection to get modified
// while still looping over it.
foreach (var trashProperty in obj.Properties().Where(x => Regex.IsMatch(x.Name, @"^trash_")).ToList())
{
trashProperty.Remove();
}
return new CustomFormatData(name, trashId, finalScore, obj);
}
}

@ -0,0 +1,9 @@
using System.IO.Abstractions;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Guide;
public interface ICustomFormatLoader
{
ICollection<CustomFormatData> LoadAllCustomFormatsAtPaths(IEnumerable<IDirectoryInfo> jsonPaths);
}

@ -0,0 +1,8 @@
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Guide;
public interface ICustomFormatParser
{
CustomFormatData ParseCustomFormatData(string guideData);
}

@ -1,7 +1,7 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat;
namespace TrashLib.Services.CustomFormat;
public interface ICachePersister
{

@ -0,0 +1,9 @@
using TrashLib.Config.Services;
using TrashLib.Services.Common;
namespace TrashLib.Services.CustomFormat;
public interface ICustomFormatUpdater
{
Task Process(bool isPreview, IEnumerable<CustomFormatConfig> config, IGuideService guideService);
}

@ -1,7 +1,7 @@
using System.Collections.ObjectModel;
using TrashLib.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Models.Cache;
namespace TrashLib.Services.CustomFormat.Models.Cache;
[CacheObjectName("custom-format-cache")]
public class CustomFormatCache

@ -1,7 +1,7 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace TrashLib.Services.Radarr.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Models;
public record CustomFormatData(
string Name,

@ -1,6 +1,6 @@
using TrashLib.Services.Radarr.Config;
using TrashLib.Config.Services;
namespace TrashLib.Services.Radarr.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Models;
public class ProcessedConfigData
{

@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Models;
public class ProcessedCustomFormatData
{

@ -1,4 +1,4 @@
namespace TrashLib.Services.Radarr.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Models;
public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score);

@ -1,4 +1,4 @@
namespace TrashLib.Services.Radarr.CustomFormat.Models;
namespace TrashLib.Services.CustomFormat.Models;
public enum FormatScoreUpdateReason
{

@ -1,10 +1,10 @@
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
using TrashLib.Config.Services;
using TrashLib.Services.Common;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.Radarr.CustomFormat.Processors;
namespace TrashLib.Services.CustomFormat.Processors;
public interface IGuideProcessorSteps
{
@ -15,14 +15,12 @@ public interface IGuideProcessorSteps
internal class GuideProcessor : IGuideProcessor
{
private readonly IRadarrGuideService _guideService;
private readonly Func<IGuideProcessorSteps> _stepsFactory;
private IList<CustomFormatData>? _guideCustomFormatJson;
private IGuideProcessorSteps _steps;
public GuideProcessor(IRadarrGuideService guideService, Func<IGuideProcessorSteps> stepsFactory)
public GuideProcessor(Func<IGuideProcessorSteps> stepsFactory)
{
_guideService = guideService;
_stepsFactory = stepsFactory;
_steps = stepsFactory();
}
@ -51,16 +49,16 @@ internal class GuideProcessor : IGuideProcessor
public IDictionary<string, List<ProcessedCustomFormatData>> DuplicatedCustomFormats
=> _steps.CustomFormat.DuplicatedCustomFormats;
public Task BuildGuideDataAsync(IReadOnlyCollection<CustomFormatConfig> config, CustomFormatCache? cache)
public Task BuildGuideDataAsync(IEnumerable<CustomFormatConfig> config, CustomFormatCache? cache,
IGuideService guideService)
{
if (_guideCustomFormatJson == null)
{
_guideCustomFormatJson = _guideService.GetCustomFormatData().ToList();
}
_guideCustomFormatJson ??= guideService.GetCustomFormatData().ToList();
var listOfConfigs = config.ToList();
// 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(_guideCustomFormatJson, config, cache);
_steps.CustomFormat.Process(_guideCustomFormatJson, listOfConfigs, 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
@ -69,7 +67,7 @@ internal class GuideProcessor : IGuideProcessor
// 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);
_steps.Config.Process(_steps.CustomFormat.ProcessedCustomFormats, listOfConfigs);
// 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

@ -1,9 +1,9 @@
using Common.Extensions;
using Serilog;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
public class ConfigStep : IConfigStep
{

@ -1,9 +1,9 @@
using Common.Extensions;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
public class CustomFormatStep : ICustomFormatStep
{

@ -1,7 +1,7 @@
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
public interface IConfigStep
{

@ -1,8 +1,8 @@
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Config.Services;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
public interface ICustomFormatStep
{

@ -1,6 +1,6 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
public interface IQualityProfileStep
{

@ -1,6 +1,6 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Services.CustomFormat.Processors.GuideSteps;
internal class QualityProfileStep : IQualityProfileStep
{

@ -1,8 +1,9 @@
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Config.Services;
using TrashLib.Services.Common;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Processors;
namespace TrashLib.Services.CustomFormat.Processors;
internal interface IGuideProcessor
{
@ -15,6 +16,8 @@ internal interface IGuideProcessor
IReadOnlyCollection<(string, string)> CustomFormatsWithOutdatedNames { get; }
IDictionary<string, List<ProcessedCustomFormatData>> DuplicatedCustomFormats { get; }
Task BuildGuideDataAsync(IReadOnlyCollection<CustomFormatConfig> config, CustomFormatCache? cache);
Task BuildGuideDataAsync(IEnumerable<CustomFormatConfig> config, CustomFormatCache? cache,
IGuideService guideService);
void Reset();
}

@ -1,8 +1,8 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.Radarr.CustomFormat.Processors;
namespace TrashLib.Services.CustomFormat.Processors;
public interface IPersistenceProcessor
{

@ -1,11 +1,10 @@
using TrashLib.Config.Services;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.Radarr.CustomFormat.Processors;
namespace TrashLib.Services.CustomFormat.Processors;
public interface IPersistenceProcessorSteps
{
@ -53,18 +52,18 @@ internal class PersistenceProcessor : IPersistenceProcessor
IEnumerable<TrashIdMapping> deletedCfsInCache,
IDictionary<string, QualityProfileCustomFormatScoreMapping> profileScores)
{
var radarrCfs = await _customFormatService.GetCustomFormats();
var serviceCfs = 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);
_steps.JsonTransactionStep.Process(guideCfs, serviceCfs);
// Step 1.1: Optionally record deletions of custom formats in cache but not in the guide
var config = (RadarrConfiguration) _configProvider.ActiveConfiguration;
var config = _configProvider.ActiveConfiguration;
if (config.DeleteOldCustomFormats)
{
_steps.JsonTransactionStep.RecordDeletions(deletedCfsInCache, radarrCfs);
_steps.JsonTransactionStep.RecordDeletions(deletedCfsInCache, serviceCfs);
}
// Step 2: For each merged CF, persist it to Radarr via its API. This will involve a combination of updates

@ -1,6 +1,6 @@
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Api;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
internal class CustomFormatApiPersistenceStep : ICustomFormatApiPersistenceStep
{

@ -1,6 +1,6 @@
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Api;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
public interface ICustomFormatApiPersistenceStep
{

@ -0,0 +1,15 @@
using Newtonsoft.Json.Linq;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
public interface IJsonTransactionStep
{
CustomFormatTransactionData Transactions { get; }
void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
IReadOnlyCollection<JObject> serviceCfs);
void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, IEnumerable<JObject> serviceCfs);
}

@ -1,7 +1,7 @@
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
public interface IQualityProfileApiPersistenceStep
{

@ -1,10 +1,10 @@
using System.Collections.ObjectModel;
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
public class CustomFormatTransactionData
{
@ -19,15 +19,15 @@ internal class JsonTransactionStep : IJsonTransactionStep
public CustomFormatTransactionData Transactions { get; } = new();
public void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
IReadOnlyCollection<JObject> radarrCfs)
IReadOnlyCollection<JObject> serviceCfs)
{
foreach (var (guideCf, radarrCf) in guideCfs
.Select(gcf => (GuideCf: gcf, RadarrCf: FindRadarrCf(radarrCfs, gcf))))
foreach (var (guideCf, serviceCf) in guideCfs
.Select(gcf => (GuideCf: gcf, ServiceCf: FindServiceCf(serviceCfs, gcf))))
{
var guideCfJson = BuildNewRadarrCf(guideCf.Json);
var guideCfJson = BuildNewServiceCf(guideCf.Json);
// no match; we add this CF as brand new
if (radarrCf == null)
if (serviceCf == null)
{
guideCf.Json = guideCfJson;
Transactions.NewCustomFormats.Add(guideCf);
@ -35,8 +35,8 @@ internal class JsonTransactionStep : IJsonTransactionStep
// found match in radarr CFs; update the existing CF
else
{
guideCf.Json = (JObject) radarrCf.DeepClone();
UpdateRadarrCf(guideCf.Json, guideCfJson);
guideCf.Json = (JObject) serviceCf.DeepClone();
UpdateServiceCf(guideCf.Json, guideCfJson);
// Set the cache for use later (like updating scores) if it hasn't been updated already.
// This handles CFs that already exist in Radarr but aren't cached (they will be added to cache
@ -46,7 +46,7 @@ internal class JsonTransactionStep : IJsonTransactionStep
guideCf.SetCache(guideCf.Json.Value<int>("id"));
}
if (!JToken.DeepEquals(radarrCf, guideCf.Json))
if (!JToken.DeepEquals(serviceCf, guideCf.Json))
{
Transactions.UpdatedCustomFormats.Add(guideCf);
}
@ -58,96 +58,96 @@ internal class JsonTransactionStep : IJsonTransactionStep
}
}
public void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, IEnumerable<JObject> radarrCfs)
public void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, IEnumerable<JObject> serviceCfs)
{
var cfs = radarrCfs.ToList();
var cfs = serviceCfs.ToList();
// 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(cfs, del.CustomFormatId, null) != null))
del => FindServiceCf(cfs, del.CustomFormatId, null) != null))
{
Transactions.DeletedCustomFormatIds.Add(del);
}
}
private static JObject? FindRadarrCf(IReadOnlyCollection<JObject> radarrCfs, ProcessedCustomFormatData guideCf)
private static JObject? FindServiceCf(IReadOnlyCollection<JObject> serviceCfs, ProcessedCustomFormatData guideCf)
{
return FindRadarrCf(radarrCfs, guideCf.CacheEntry?.CustomFormatId, guideCf.Name);
return FindServiceCf(serviceCfs, guideCf.CacheEntry?.CustomFormatId, guideCf.Name);
}
private static JObject? FindRadarrCf(IReadOnlyCollection<JObject> radarrCfs, int? cfId, string? cfName)
private static JObject? FindServiceCf(IReadOnlyCollection<JObject> serviceCfs, int? cfId, string? cfName)
{
JObject? match = null;
// Try to find match in cache first
if (cfId != null)
{
match = radarrCfs.FirstOrDefault(rcf => cfId == rcf.Value<int>("id"));
match = serviceCfs.FirstOrDefault(rcf => cfId == rcf.Value<int>("id"));
}
// 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.Value<string>("name")));
match = serviceCfs.FirstOrDefault(rcf => cfName.EqualsIgnoreCase(rcf.Value<string>("name")));
}
return match;
}
private static void UpdateRadarrCf(JObject cfToModify, JObject cfToMergeFrom)
private static void UpdateServiceCf(JObject cfToModify, JObject cfToMergeFrom)
{
MergeProperties(cfToModify, cfToMergeFrom, JTokenType.Array);
var radarrSpecs = cfToModify["specifications"]?.Children<JObject>() ?? new JEnumerable<JObject>();
var serviceSpecs = cfToModify["specifications"]?.Children<JObject>() ?? new JEnumerable<JObject>();
var guideSpecs = cfToMergeFrom["specifications"]?.Children<JObject>() ?? new JEnumerable<JObject>();
var matchedGuideSpecs = guideSpecs
.GroupBy(gs => radarrSpecs.FirstOrDefault(gss => KeyMatch(gss, gs, "name")))
.SelectMany(kvp => kvp.Select(gs => new {GuideSpec = gs, RadarrSpec = kvp.Key}));
.GroupBy(gs => serviceSpecs.FirstOrDefault(gss => KeyMatch(gss, gs, "name")))
.SelectMany(kvp => kvp.Select(gs => new {GuideSpec = gs, ServiceSpec = kvp.Key}));
var newRadarrSpecs = new JArray();
var newServiceSpecs = new JArray();
foreach (var match in matchedGuideSpecs)
{
if (match.RadarrSpec != null)
if (match.ServiceSpec != null)
{
MergeProperties(match.RadarrSpec, match.GuideSpec);
newRadarrSpecs.Add(match.RadarrSpec);
MergeProperties(match.ServiceSpec, match.GuideSpec);
newServiceSpecs.Add(match.ServiceSpec);
}
else
{
newRadarrSpecs.Add(match.GuideSpec);
newServiceSpecs.Add(match.GuideSpec);
}
}
cfToModify["specifications"] = newRadarrSpecs;
cfToModify["specifications"] = newServiceSpecs;
}
private static bool KeyMatch(JObject left, JObject right, string keyName)
=> left.Value<string>(keyName) == right.Value<string>(keyName);
private static void MergeProperties(JObject radarrCf, JObject guideCfJson,
private static void MergeProperties(JObject serviceCf, 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))
serviceCf.TryGetValue(guideProp.Name, out var serviceArray))
{
((JArray) radarrArray).Merge(guideProp.Value, new JsonMergeSettings
((JArray) serviceArray).Merge(guideProp.Value, new JsonMergeSettings
{
MergeArrayHandling = MergeArrayHandling.Merge
});
}
else
{
radarrCf[guideProp.Name] = guideProp.Value;
serviceCf[guideProp.Name] = guideProp.Value;
}
}
}
private static JObject BuildNewRadarrCf(JObject jsonPayload)
private static JObject BuildNewServiceCf(JObject jsonPayload)
{
// Information on required fields from nitsua
/*

@ -1,9 +1,9 @@
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Api;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.CustomFormat.Api;
using TrashLib.Services.CustomFormat.Models;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Services.CustomFormat.Processors.PersistenceSteps;
internal class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceStep
{
@ -16,13 +16,13 @@ internal class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceS
public async Task Process(IQualityProfileService api,
IDictionary<string, QualityProfileCustomFormatScoreMapping> cfScores)
{
var radarrProfiles = await api.GetQualityProfiles();
var serviceProfiles = await api.GetQualityProfiles();
// Match quality profiles in Radarr to ones the user put in their config.
// For each match, we return a tuple including the list of custom format scores ("formatItems").
// Using GroupJoin() because we want a LEFT OUTER JOIN so we can list which quality profiles in config
// do not match profiles in Radarr.
var profileScores = cfScores.GroupJoin(radarrProfiles,
var profileScores = cfScores.GroupJoin(serviceProfiles,
s => s.Key,
p => p.Value<string>("name"),
(s, p) => (s.Key, s.Value, p.SelectMany(pi => pi.Children<JObject>("formatItems")).ToList()),

@ -7,24 +7,6 @@ namespace TrashLib.Services.Radarr.Config;
public class RadarrConfiguration : ServiceConfiguration
{
public QualityDefinitionConfig? QualityDefinition { get; init; }
public ICollection<CustomFormatConfig> CustomFormats { get; init; } = new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class CustomFormatConfig
{
public ICollection<string> Names { get; init; } = new List<string>();
public ICollection<string> TrashIds { get; init; } = new List<string>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>();
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig
{
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]

@ -1,6 +1,7 @@
using Common.FluentValidation;
using FluentValidation;
using JetBrains.Annotations;
using TrashLib.Config.Services;
namespace TrashLib.Services.Radarr.Config;

@ -1,9 +0,0 @@
namespace TrashLib.Services.Radarr.CustomFormat;
public enum ApiOperationType
{
Create,
Update,
NoChange,
Delete
}

@ -1,10 +0,0 @@
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr.CustomFormat.Guide;
public interface IRadarrGuideService
{
ICollection<CustomFormatData> GetCustomFormatData();
ICollection<RadarrQualityData> GetQualities();
}

@ -1,82 +0,0 @@
using System.IO.Abstractions;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Text.RegularExpressions;
using Common.Extensions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr.CustomFormat.Guide;
public class LocalRepoRadarrGuideService : IRadarrGuideService
{
private readonly IRepoPathsFactory _pathsFactory;
private readonly ILogger _log;
private readonly QualityGuideParser<RadarrQualityData> _parser;
public LocalRepoRadarrGuideService(IRepoPathsFactory pathsFactory, ILogger log)
{
_pathsFactory = pathsFactory;
_log = log;
_parser = new QualityGuideParser<RadarrQualityData>(log);
}
public ICollection<RadarrQualityData> GetQualities()
=> _parser.GetQualities(_pathsFactory.Create().RadarrQualityPaths);
public ICollection<CustomFormatData> GetCustomFormatData()
{
var paths = _pathsFactory.Create();
var jsonFiles = paths.RadarrCustomFormatPaths
.SelectMany(x => x.GetFiles("*.json"));
return jsonFiles.ToObservable()
.Select(x => Observable.Defer(() => LoadJsonFromFile(x)))
.Merge(8)
.NotNull()
.ToEnumerable()
.ToList();
}
private IObservable<CustomFormatData?> LoadJsonFromFile(IFileInfo file)
{
return Observable.Using(file.OpenText, x => x.ReadToEndAsync().ToObservable())
.Do(_ => _log.Debug("Parsing CF Json: {Name}", file.Name))
.Select(ParseCustomFormatData)
.Catch((JsonException e) =>
{
_log.Warning("Failed to parse JSON file: {File} ({Reason})", file.Name, e.Message);
return Observable.Empty<CustomFormatData>();
});
}
public static CustomFormatData ParseCustomFormatData(string guideData)
{
var obj = JObject.Parse(guideData);
var name = obj.ValueOrThrow<string>("name");
var trashId = obj.ValueOrThrow<string>("trash_id");
int? finalScore = null;
if (obj.TryGetValue("trash_score", out var score))
{
finalScore = (int) score;
}
// Remove any properties starting with "trash_". Those are metadata that are not meant for Radarr itself Radarr
// supposedly drops this anyway, but I prefer it to be removed. ToList() is important here since removing the
// property itself modifies the collection, and we don't want the collection to get modified while still looping
// over it.
foreach (var trashProperty in obj.Properties().Where(x => Regex.IsMatch(x.Name, @"^trash_")).ToList())
{
trashProperty.Remove();
}
return new CustomFormatData(name, trashId, finalScore, obj);
}
}

@ -1,8 +0,0 @@
using TrashLib.Services.Radarr.Config;
namespace TrashLib.Services.Radarr.CustomFormat;
public interface ICustomFormatUpdater
{
Task Process(bool isPreview, RadarrConfiguration config);
}

@ -1,15 +0,0 @@
using Newtonsoft.Json.Linq;
using TrashLib.Services.Radarr.CustomFormat.Models;
using TrashLib.Services.Radarr.CustomFormat.Models.Cache;
namespace TrashLib.Services.Radarr.CustomFormat.Processors.PersistenceSteps;
public interface IJsonTransactionStep
{
CustomFormatTransactionData Transactions { get; }
void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
IReadOnlyCollection<JObject> radarrCfs);
void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, IEnumerable<JObject> radarrCfs);
}

@ -1,4 +1,4 @@
namespace TrashLib.Services.Radarr.CustomFormat;
namespace TrashLib.Services.Radarr;
public interface IRadarrGuideDataLister
{

@ -0,0 +1,9 @@
using TrashLib.Services.Common;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr;
public interface IRadarrGuideService : IGuideService
{
ICollection<RadarrQualityData> GetQualities();
}

@ -0,0 +1,31 @@
using Serilog;
using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr;
public class LocalRepoRadarrGuideService : IRadarrGuideService
{
private readonly IRepoPathsFactory _pathsFactory;
private readonly ICustomFormatLoader _cfLoader;
private readonly QualityGuideParser<RadarrQualityData> _parser;
public LocalRepoRadarrGuideService(IRepoPathsFactory pathsFactory, ILogger log, ICustomFormatLoader cfLoader)
{
_pathsFactory = pathsFactory;
_cfLoader = cfLoader;
_parser = new QualityGuideParser<RadarrQualityData>(log);
}
public ICollection<RadarrQualityData> GetQualities()
=> _parser.GetQualities(_pathsFactory.Create().RadarrQualityPaths);
public ICollection<CustomFormatData> GetCustomFormatData()
{
var paths = _pathsFactory.Create();
return _cfLoader.LoadAllCustomFormatsAtPaths(paths.RadarrCustomFormatPaths);
}
}

@ -2,7 +2,6 @@ using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.CustomFormat.Guide;
using TrashLib.Services.Radarr.QualityDefinition.Api;
using TrashLib.Services.Radarr.QualityDefinition.Api.Objects;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save