diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c94e41..0bfed6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/schemas/config-schema.json b/schemas/config-schema.json index b766c374..d5f0b071 100644 --- a/schemas/config-schema.json +++ b/schemas/config-schema.json @@ -5,131 +5,42 @@ "additionalProperties": false, "properties": { "sonarr": { - "type": "array", - "minItems": 1, - "items": { - "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", - "minLength": 1, - "description": "The API key from Sonarr." - }, - "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, - "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": { - "type": "array", - "description": "A list of one or more strings representing tags that will be applied to this release profile.", - "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." - }, - "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." - } - } - } - } + "oneOf": [ + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/sonarr_instance" + } + }, + { + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/sonarr_instance" } } } - } + ] }, "radarr": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["base_url", "api_key"], - "properties": { - "base_url": { - "$ref": "#/$defs/base_url", - "examples": [ - "http://localhost:7878", - "https://radarr.mydomain.com", - "https://mydomain.com/radarr" - ] - }, - "api_key": { - "type": "string", - "minLength": 1, - "description": "The API key from Radarr." - }, - "quality_definition": { - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": { - "type": "string" - }, - "preferred_ratio": { - "type": "number", - "default": 1.0, - "minimum": 0.0, - "maximum": 1.0 - } + "oneOf": [ + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/radarr_instance" + } + }, + { + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/radarr_instance" } - }, - "delete_old_custom_formats": { - "$ref": "#/$defs/delete_old_custom_formats" - }, - "custom_formats": { - "$ref": "#/$defs/custom_formats" } } - } + ] } }, "$defs": { @@ -193,6 +104,125 @@ } } } + }, + "radarr_instance": { + "type": "object", + "additionalProperties": false, + "required": ["base_url", "api_key"], + "properties": { + "base_url": { + "$ref": "#/$defs/base_url", + "examples": [ + "http://localhost:7878", + "https://radarr.mydomain.com", + "https://mydomain.com/radarr" + ] + }, + "api_key": { + "type": "string", + "minLength": 1, + "description": "The API key from Radarr." + }, + "quality_definition": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string" + }, + "preferred_ratio": { + "type": "number", + "default": 1.0, + "minimum": 0.0, + "maximum": 1.0 + } + } + }, + "delete_old_custom_formats": { + "$ref": "#/$defs/delete_old_custom_formats" + }, + "custom_formats": { + "$ref": "#/$defs/custom_formats" + } + } + }, + "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", + "minLength": 1, + "description": "The API key from Sonarr." + }, + "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, + "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": { + "type": "array", + "description": "A list of one or more strings representing tags that will be applied to this release profile.", + "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." + }, + "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." + } + } + } + } + } + } + } } } } diff --git a/src/Recyclarr.Tests/Command/Helpers/CacheStoragePathTest.cs b/src/Recyclarr.Tests/Command/Helpers/CacheStoragePathTest.cs new file mode 100644 index 00000000..c608124c --- /dev/null +++ b/src/Recyclarr.Tests/Command/Helpers/CacheStoragePathTest.cs @@ -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(); + config.BaseUrl = "something"; + config.Name = ""; + + using var scope = Container.BeginLifetimeScope(builder => + { + builder.RegisterInstance(config).AsImplementedInterfaces(); + }); + + var sut = scope.Resolve(); + 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(); + config.BaseUrl = "something"; + config.Name = "thename"; + + using var scope = Container.BeginLifetimeScope(builder => + { + builder.RegisterInstance(config).AsImplementedInterfaces(); + }); + + var sut = scope.Resolve(); + var result = sut.CalculatePath("obj"); + + result.FullName.Should().MatchRegex(@".*[/\\]thename[/\\]obj\.json$"); + } +} diff --git a/src/Recyclarr.Tests/Config/ConfigurationLoaderTest.cs b/src/Recyclarr.Tests/Config/ConfigurationLoaderTest.cs index ae29dc81..99dbff10 100644 --- a/src/Recyclarr.Tests/Config/ConfigurationLoaderTest.cs +++ b/src/Recyclarr.Tests/Config/ConfigurationLoaderTest.cs @@ -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 CustomFormats => new List(); - 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 { new() diff --git a/src/Recyclarr.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml b/src/Recyclarr.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml index 4db0fb2f..78f74aa2 100644 --- a/src/Recyclarr.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml +++ b/src/Recyclarr.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml @@ -1,5 +1,6 @@ sonarr: - - base_url: http://localhost:8989 + name: + base_url: http://localhost:8989 api_key: 95283e6b156c42f3af8a9b16173f876b release_profiles: - trash_ids: [123] diff --git a/src/Recyclarr/Command/Helpers/CacheStoragePath.cs b/src/Recyclarr/Command/Helpers/CacheStoragePath.cs index f665e862..3ea04eb6 100644 --- a/src/Recyclarr/Command/Helpers/CacheStoragePath.cs +++ b/src/Recyclarr/Command/Helpers/CacheStoragePath.cs @@ -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"); } } diff --git a/src/Recyclarr/Config/ConfigurationLoader.cs b/src/Recyclarr/Config/ConfigurationLoader.cs index ead050a8..0f79927f 100644 --- a/src/Recyclarr/Config/ConfigurationLoader.cs +++ b/src/Recyclarr/Config/ConfigurationLoader.cs @@ -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 : IConfigurationLoader - where T : IServiceConfiguration + where T : ServiceConfiguration { + private readonly ILogger _log; private readonly IDeserializer _deserializer; private readonly IFileSystem _fileSystem; private readonly IValidator _validator; public ConfigurationLoader( + ILogger log, IFileSystem fileSystem, IYamlSerializerFactory yamlFactory, IValidator validator) { + _log = log; _fileSystem = fileSystem; _validator = validator; _deserializer = yamlFactory.CreateDeserializer(); @@ -47,14 +51,36 @@ public class ConfigurationLoader : IConfigurationLoader continue; } - var configs = _deserializer.Deserialize?>(parser); - if (configs == null) + List? configs; + switch (parser.Current) { - parser.SkipThisAndNestedEvents(); - continue; + case MappingStart: + configs = _deserializer.Deserialize>(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>(parser); + break; + + default: + configs = null; + break; + } + + if (configs is not null) + { + ValidateConfigs(configSection, configs, validConfigs); } - ValidateConfigs(configSection, configs, validConfigs); parser.SkipThisAndNestedEvents(); } diff --git a/src/TrashLib.TestLibrary/TestConfig.cs b/src/TrashLib.TestLibrary/TestConfig.cs new file mode 100644 index 00000000..8231a899 --- /dev/null +++ b/src/TrashLib.TestLibrary/TestConfig.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using TrashLib.Config.Services; + +namespace TrashLib.TestLibrary; + +[UsedImplicitly] +public class TestConfig : ServiceConfiguration +{ + public TestConfig() + { + Name = "Test"; + } +} diff --git a/src/TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs b/src/TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs index 1ce5d973..f79c811a 100644 --- a/src/TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs +++ b/src/TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs @@ -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>(); @@ -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", diff --git a/src/TrashLib/Config/Services/IServiceConfiguration.cs b/src/TrashLib/Config/Services/IServiceConfiguration.cs index fe03bd9e..02bc6a2d 100644 --- a/src/TrashLib/Config/Services/IServiceConfiguration.cs +++ b/src/TrashLib/Config/Services/IServiceConfiguration.cs @@ -2,6 +2,7 @@ namespace TrashLib.Config.Services; public interface IServiceConfiguration { + string Name { get; } string BaseUrl { get; } string ApiKey { get; } ICollection CustomFormats { get; } diff --git a/src/TrashLib/Config/Services/ServiceConfiguration.cs b/src/TrashLib/Config/Services/ServiceConfiguration.cs index eae6c1b8..69ab8a87 100644 --- a/src/TrashLib/Config/Services/ServiceConfiguration.cs +++ b/src/TrashLib/Config/Services/ServiceConfiguration.cs @@ -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 CustomFormats { get; init; } = new List();