feat: Named instances support

Use mapping-style instead of array-style for instances in configuration
YAML.
pull/151/head
Robert Dailey 2 years ago committed by Robert Dailey
parent 4ae54d8f54
commit 1d604b141b

@ -19,6 +19,12 @@ changes you need to make.
- Secrets support. You can now store sensitive information from your configuration YAML such as
`api_key` and `base_url` in a `secrets.yml` file. See [the secrets docs][secrets] for more info.
Huge thanks to @voltron4lyfe for this one. (#105, #139)
- Named instances are now supported in configuration YAML.
### Changed
- Deprecated array-style instances in configuration YAML. Read more about this in the v3.0 Upgrade
Guide.
### Removed

@ -5,79 +5,99 @@
"additionalProperties": false,
"properties": {
"sonarr": {
"oneOf": [
{
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/sonarr_instance"
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["base_url", "api_key"],
"properties": {
"base_url": {
"$ref": "#/$defs/base_url",
"examples": [
"http://localhost:8989",
"https://sonarr.mydomain.com",
"https://mydomain.com/sonarr"
"patternProperties": {
"^.*$": {
"$ref": "#/$defs/sonarr_instance"
}
}
}
]
},
"api_key": {
"radarr": {
"oneOf": [
{
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/radarr_instance"
}
},
{
"type": "object",
"patternProperties": {
"^.*$": {
"$ref": "#/$defs/radarr_instance"
}
}
}
]
}
},
"$defs": {
"trash_id": {
"type": "string",
"minLength": 1,
"description": "The API key from Sonarr."
"minLength": 32,
"pattern": "^[0-9a-fA-F]+$"
},
"quality_definition": {
"type": "string"
"trash_ids_list": {
"type": "array",
"description": "A list of one or more Trash IDs taken from the Trash Guide Sonarr JSON files.",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/trash_id"
}
},
"base_url": {
"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": {
"$ref": "#/$defs/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": {
"$ref": "#/$defs/custom_formats"
},
"release_profiles": {
"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.",
"required": ["trash_ids"],
"properties": {
"trash_ids": {
"$ref": "#/$defs/trash_ids_list"
},
"strict_negative_scores": {
"type": "boolean",
"default": false,
"description": "Enables preferred term scores less than 0 to be instead treated as \"Must Not Contain\" (ignored) terms."
},
"tags": {
"quality_profiles": {
"type": "array",
"description": "A list of one or more strings representing tags that will be applied to this release profile.",
"description": "One or more quality profiles to update with the scores from the specified custom formats.",
"minItems": 1,
"items": {
"type": "string"
}
},
"filter": {
"type": "object",
"additionalProperties": false,
"description": "Defines various ways that release profile terms from the guide are synchronized with Sonarr.",
"oneOf": [
{
"required": ["include"]
},
{
"required": ["exclude"]
}
],
"properties": {
"include": {
"$ref": "#/$defs/trash_ids_list",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be included in the created Release Profile in Sonarr."
"name": {
"type": "string",
"description": "The name of one of the quality profiles in Radarr."
},
"exclude": {
"$ref": "#/$defs/trash_ids_list",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be excluded from the created Release Profile in Sonarr."
}
}
"score": {
"type": "integer",
"description": "A positive or negative number representing the score to apply to *all* custom formats listed in the trash_ids list."
},
"reset_unmatched_scores": {
"type": "boolean",
"description": "If set to true, enables setting scores to 0 in quality profiles where either a CF was not mentioned in the trash_ids array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false
}
}
}
@ -85,10 +105,7 @@
}
}
},
"radarr": {
"type": "array",
"minItems": 1,
"items": {
"radarr_instance": {
"type": "object",
"additionalProperties": false,
"required": ["base_url", "api_key"],
@ -129,64 +146,77 @@
"$ref": "#/$defs/custom_formats"
}
}
}
}
},
"$defs": {
"trash_id": {
"type": "string",
"minLength": 32,
"pattern": "^[0-9a-fA-F]+$"
},
"trash_ids_list": {
"type": "array",
"description": "A list of one or more Trash IDs taken from the Trash Guide Sonarr JSON files.",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/trash_id"
}
},
"sonarr_instance": {
"type": "object",
"additionalProperties": false,
"required": ["base_url", "api_key"],
"properties": {
"base_url": {
"$ref": "#/$defs/base_url",
"examples": [
"http://localhost:8989",
"https://sonarr.mydomain.com",
"https://mydomain.com/sonarr"
]
},
"api_key": {
"type": "string",
"pattern": "^https?",
"description": "The base URL of your instance. Basically this is the URL you bookmark to get to the front page."
"minLength": 1,
"description": "The API key from Sonarr."
},
"quality_definition": {
"type": "string"
},
"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": {
"$ref": "#/$defs/custom_formats"
},
"release_profiles": {
"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.",
"required": ["trash_ids"],
"properties": {
"trash_ids": {
"$ref": "#/$defs/trash_ids_list"
},
"quality_profiles": {
"strict_negative_scores": {
"type": "boolean",
"default": false,
"description": "Enables preferred term scores less than 0 to be instead treated as \"Must Not Contain\" (ignored) terms."
},
"tags": {
"type": "array",
"description": "One or more quality profiles to update with the scores from the specified custom formats.",
"minItems": 1,
"description": "A list of one or more strings representing tags that will be applied to this release profile.",
"items": {
"properties": {
"name": {
"type": "string",
"description": "The name of one of the quality profiles in Radarr."
"type": "string"
}
},
"score": {
"type": "integer",
"description": "A positive or negative number representing the score to apply to *all* custom formats listed in the trash_ids list."
"filter": {
"type": "object",
"additionalProperties": false,
"description": "Defines various ways that release profile terms from the guide are synchronized with Sonarr.",
"oneOf": [
{
"required": ["include"]
},
"reset_unmatched_scores": {
"type": "boolean",
"description": "If set to true, enables setting scores to 0 in quality profiles where either a CF was not mentioned in the trash_ids array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false
{
"required": ["exclude"]
}
],
"properties": {
"include": {
"$ref": "#/$defs/trash_ids_list",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be included in the created Release Profile in Sonarr."
},
"exclude": {
"$ref": "#/$defs/trash_ids_list",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be excluded from the created Release Profile in Sonarr."
}
}
}
}

@ -0,0 +1,50 @@
using Autofac;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Command.Helpers;
using Recyclarr.TestLibrary;
using TrashLib.Config.Services;
namespace Recyclarr.Tests.Command.Helpers;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CacheStoragePathTest : IntegrationFixture
{
[Test]
public void Use_guid_when_empty_name()
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = "something";
config.Name = "";
using var scope = Container.BeginLifetimeScope(builder =>
{
builder.RegisterInstance(config).AsImplementedInterfaces();
});
var sut = scope.Resolve<CacheStoragePath>();
var result = sut.CalculatePath("obj");
result.FullName.Should().MatchRegex(@".*[/\\][a-f0-9]+[/\\]obj\.json$");
}
[Test]
public void Use_name_when_not_empty()
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = "something";
config.Name = "thename";
using var scope = Container.BeginLifetimeScope(builder =>
{
builder.RegisterInstance(config).AsImplementedInterfaces();
});
var sut = scope.Resolve<CacheStoragePath>();
var result = sut.CalculatePath("obj");
result.FullName.Should().MatchRegex(@".*[/\\]thename[/\\]obj\.json$");
}
}

@ -1,4 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
@ -9,14 +8,13 @@ using Common.Extensions;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using JetBrains.Annotations;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Config;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib.Config.Services;
using TrashLib.Services.Sonarr.Config;
using TrashLib.TestLibrary;
using YamlDotNet.Core;
namespace Recyclarr.Tests.Config;
@ -31,19 +29,6 @@ public class ConfigurationLoaderTest : IntegrationFixture
return new StringReader(testData.ReadData(file));
}
[UsedImplicitly]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("Microsoft.Design", "CA1034",
Justification = "YamlDotNet requires this type to be public so it may access it")]
[SuppressMessage("Performance", "CA1822", MessageId = "Mark members as static")]
public class TestConfig : IServiceConfiguration
{
public string BaseUrl => "";
public string ApiKey => "";
public ICollection<CustomFormatConfig> CustomFormats => new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats => false;
}
[Test]
public void Load_many_iterations_of_config()
{
@ -92,6 +77,7 @@ public class ConfigurationLoaderTest : IntegrationFixture
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
Name = "name",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()

@ -1,5 +1,6 @@
sonarr:
- base_url: http://localhost:8989
name:
base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b
release_profiles:
- trash_ids: [123]

@ -34,7 +34,7 @@ public class CacheStoragePath : ICacheStoragePath
{
return _paths.CacheDirectory
.SubDirectory(_serviceCommand.Name.ToLower())
.SubDirectory(BuildServiceGuid())
.SubDirectory(_config.Name.Any() ? _config.Name : BuildServiceGuid())
.File(cacheObjectName + ".json");
}
}

@ -1,5 +1,6 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using FluentValidation;
using Serilog;
using TrashLib.Config;
using TrashLib.Config.Services;
using YamlDotNet.Core;
@ -9,17 +10,20 @@ using YamlDotNet.Serialization;
namespace Recyclarr.Config;
public class ConfigurationLoader<T> : IConfigurationLoader<T>
where T : IServiceConfiguration
where T : ServiceConfiguration
{
private readonly ILogger _log;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fileSystem;
private readonly IValidator<T> _validator;
public ConfigurationLoader(
ILogger log,
IFileSystem fileSystem,
IYamlSerializerFactory yamlFactory,
IValidator<T> validator)
{
_log = log;
_fileSystem = fileSystem;
_validator = validator;
_deserializer = yamlFactory.CreateDeserializer();
@ -47,14 +51,36 @@ public class ConfigurationLoader<T> : IConfigurationLoader<T>
continue;
}
var configs = _deserializer.Deserialize<List<T>?>(parser);
if (configs == null)
List<T>? configs;
switch (parser.Current)
{
parser.SkipThisAndNestedEvents();
continue;
case MappingStart:
configs = _deserializer.Deserialize<Dictionary<string, T>>(parser)
.Select(kvp =>
{
kvp.Value.Name = kvp.Key;
return kvp.Value;
})
.ToList();
break;
case SequenceStart:
_log.Warning(
"Found array-style list of instances instead of named-style. Array-style lists of Sonarr/Radarr " +
"instances are deprecated");
configs = _deserializer.Deserialize<List<T>>(parser);
break;
default:
configs = null;
break;
}
if (configs is not null)
{
ValidateConfigs(configSection, configs, validConfigs);
}
parser.SkipThisAndNestedEvents();
}

@ -0,0 +1,13 @@
using JetBrains.Annotations;
using TrashLib.Config.Services;
namespace TrashLib.TestLibrary;
[UsedImplicitly]
public class TestConfig : ServiceConfiguration
{
public TestConfig()
{
Name = "Test";
}
}

@ -4,6 +4,7 @@ using FluentValidation;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TrashLib.Config.Services;
using TrashLib.TestLibrary;
namespace TrashLib.Tests.Config.Services;
@ -15,7 +16,7 @@ public class ServiceConfigurationTest : IntegrationFixture
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new ServiceConfiguration();
var config = new TestConfig();
var validator = Container.Resolve<IValidator<ServiceConfiguration>>();
@ -36,7 +37,7 @@ public class ServiceConfigurationTest : IntegrationFixture
[Test]
public void Fail_when_trash_ids_missing()
{
var config = new ServiceConfiguration
var config = new TestConfig
{
BaseUrl = "valid",
ApiKey = "valid",

@ -2,6 +2,7 @@ namespace TrashLib.Config.Services;
public interface IServiceConfiguration
{
string Name { get; }
string BaseUrl { get; }
string ApiKey { get; }
ICollection<CustomFormatConfig> CustomFormats { get; }

@ -2,10 +2,12 @@ using JetBrains.Annotations;
namespace TrashLib.Config.Services;
public class ServiceConfiguration : IServiceConfiguration
public abstract class ServiceConfiguration : IServiceConfiguration
{
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
// Name is set dynamically by
public string Name { get; set; } = "";
public string BaseUrl { get; set; } = "";
public string ApiKey { get; set; } = "";
public ICollection<CustomFormatConfig> CustomFormats { get; init; } =
new List<CustomFormatConfig>();

Loading…
Cancel
Save