feat: Support `*.yaml` extension

pull/201/head
Robert Dailey 1 year ago
parent 4f0e365dd5
commit a8aaca42cc

@ -13,6 +13,11 @@ changes you may need to make.
[breaking5]: https://recyclarr.dev/wiki/upgrade-guide/v5.0 [breaking5]: https://recyclarr.dev/wiki/upgrade-guide/v5.0
### Added
- The `*.yaml` extension is now accepted for all YAML files (e.g. `settings.yaml`, `recyclarr.yaml`)
in addition to `*.yml` (which was already supported).
### Changed ### Changed
- API Key is now sent via the `X-Api-Key` header instead of the `apikey` query parameter. This - API Key is now sent via the `X-Api-Key` header instead of the `apikey` query parameter. This

@ -45,6 +45,7 @@
<PackageVersion Include="AutoFixture.NUnit3" Version="4.18.0" /> <PackageVersion Include="AutoFixture.NUnit3" Version="4.18.0" />
<PackageVersion Include="coverlet.collector" Version="3.2.0" /> <PackageVersion Include="coverlet.collector" Version="3.2.0" />
<PackageVersion Include="FluentAssertions" Version="6.11.0" /> <PackageVersion Include="FluentAssertions" Version="6.11.0" />
<PackageVersion Include="FluentAssertions.Analyzers" Version="0.17.2" />
<PackageVersion Include="FluentAssertions.Json" Version="6.1.0" /> <PackageVersion Include="FluentAssertions.Json" Version="6.1.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.2.1" /> <PackageVersion Include="GitHubActionsTestLogger" Version="2.2.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
@ -63,4 +64,4 @@
<PackageVersion Include="System.Net.Http" Version="4.3.4" /> <PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

@ -26,7 +26,10 @@ public class ConfigCreationProcessor : IConfigCreationProcessor
public async Task Process(string? configFilePath) public async Task Process(string? configFilePath)
{ {
var configFile = configFilePath is null ? _paths.ConfigPath : _fs.FileInfo.New(configFilePath); var configFile = configFilePath is null
? _paths.AppDataDirectory.File("recyclarr.yml")
: _fs.FileInfo.New(configFilePath);
if (configFile.Exists) if (configFile.Exists)
{ {
throw new FileExistsException(configFile.FullName); throw new FileExistsException(configFile.FullName);

@ -0,0 +1,16 @@
namespace Recyclarr.Common;
public class ConflictingYamlFilesException : Exception
{
public ConflictingYamlFilesException(IEnumerable<string> supportedFiles)
: base(BuildMessage(supportedFiles))
{
}
private static string BuildMessage(IEnumerable<string> supportedFiles)
{
return
"Expected only 1 of the following files to exist, but found more than one: " +
string.Join(", ", supportedFiles);
}
}

@ -73,4 +73,20 @@ public static class FileSystemExtensions
return subdirectories.Aggregate(dir, return subdirectories.Aggregate(dir,
(d, s) => d.FileSystem.DirectoryInfo.New(d.FileSystem.Path.Combine(d.FullName, s))); (d, s) => d.FileSystem.DirectoryInfo.New(d.FileSystem.Path.Combine(d.FullName, s)));
} }
public static IFileInfo? YamlFile(this IDirectoryInfo dir, string yamlFilenameNoExtension)
{
var supportedFiles = new[] {$"{yamlFilenameNoExtension}.yml", $"{yamlFilenameNoExtension}.yaml"};
var configs = supportedFiles
.Select(dir.File)
.Where(x => x.Exists)
.ToList();
if (configs.Count > 1)
{
throw new ConflictingYamlFilesException(supportedFiles);
}
return configs.FirstOrDefault();
}
} }

@ -8,6 +8,7 @@
<PackageReference Include="Serilog" /> <PackageReference Include="Serilog" />
<PackageReference Include="Spectre.Console" /> <PackageReference Include="Spectre.Console" />
<PackageReference Include="System.Reactive" /> <PackageReference Include="System.Reactive" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Extensions" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="TestableIO.System.IO.Abstractions" /> <PackageReference Include="TestableIO.System.IO.Abstractions" />
<PackageReference Include="YamlDotNet" /> <PackageReference Include="YamlDotNet" />

@ -11,14 +11,9 @@ public class AppPaths : IAppPaths
AppDataDirectory = appDataPath; AppDataDirectory = appDataPath;
} }
public static string DefaultConfigFilename => "recyclarr.yml";
public static string DefaultAppDataDirectoryName => "recyclarr"; public static string DefaultAppDataDirectoryName => "recyclarr";
public IDirectoryInfo AppDataDirectory { get; } public IDirectoryInfo AppDataDirectory { get; }
public IFileInfo ConfigPath => AppDataDirectory.File(DefaultConfigFilename);
public IFileInfo SettingsPath => AppDataDirectory.File("settings.yml");
public IFileInfo SecretsPath => AppDataDirectory.File("secrets.yml");
public IDirectoryInfo LogDirectory => AppDataDirectory.SubDir("logs", "cli"); public IDirectoryInfo LogDirectory => AppDataDirectory.SubDir("logs", "cli");
public IDirectoryInfo ReposDirectory => AppDataDirectory.SubDir("repositories"); public IDirectoryInfo ReposDirectory => AppDataDirectory.SubDir("repositories");
public IDirectoryInfo CacheDirectory => AppDataDirectory.SubDir("cache"); public IDirectoryInfo CacheDirectory => AppDataDirectory.SubDir("cache");

@ -1,4 +1,5 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
@ -19,12 +20,15 @@ public class ConfigurationFinder : IConfigurationFinder
if (_paths.ConfigsDirectory.Exists) if (_paths.ConfigsDirectory.Exists)
{ {
configs.AddRange(_paths.ConfigsDirectory.EnumerateFiles("*.yml")); var extensions = new[] {"*.yml", "*.yaml"};
var files = extensions.SelectMany(x => _paths.ConfigsDirectory.EnumerateFiles(x));
configs.AddRange(files);
} }
if (_paths.ConfigPath.Exists) var configPath = _paths.AppDataDirectory.YamlFile("recyclarr");
if (configPath is not null)
{ {
configs.Add(_paths.ConfigPath); configs.Add(configPath);
} }
return configs; return configs;

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
@ -21,9 +22,10 @@ public class SecretsProvider : ISecretsProvider
{ {
var result = new Dictionary<string, string>(); var result = new Dictionary<string, string>();
if (_paths.SecretsPath.Exists) var yamlPath = _paths.AppDataDirectory.YamlFile("secrets");
if (yamlPath is not null)
{ {
using var stream = _paths.SecretsPath.OpenText(); using var stream = yamlPath.OpenText();
var deserializer = new DeserializerBuilder().Build(); var deserializer = new DeserializerBuilder().Build();
var dict = deserializer.Deserialize<Dictionary<string, string>?>(stream); var dict = deserializer.Deserialize<Dictionary<string, string>?>(stream);
if (dict is not null) if (dict is not null)

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Yaml; using Recyclarr.TrashLib.Config.Yaml;
@ -23,14 +24,15 @@ public class SettingsProvider : ISettingsProvider
private SettingsValues LoadOrCreateSettingsFile(IYamlSerializerFactory serializerFactory) private SettingsValues LoadOrCreateSettingsFile(IYamlSerializerFactory serializerFactory)
{ {
if (!_paths.SettingsPath.Exists) var yamlPath = _paths.AppDataDirectory.YamlFile("settings");
if (yamlPath is null)
{ {
CreateDefaultSettingsFile(); yamlPath = CreateDefaultSettingsFile();
} }
try try
{ {
using var stream = _paths.SettingsPath.OpenText(); using var stream = yamlPath.OpenText();
var deserializer = serializerFactory.CreateDeserializer(); var deserializer = serializerFactory.CreateDeserializer();
return deserializer.Deserialize<SettingsValues?>(stream.ReadToEnd()) ?? new SettingsValues(); return deserializer.Deserialize<SettingsValues?>(stream.ReadToEnd()) ?? new SettingsValues();
} }
@ -46,7 +48,7 @@ public class SettingsProvider : ISettingsProvider
} }
} }
private void CreateDefaultSettingsFile() private IFileInfo CreateDefaultSettingsFile()
{ {
const string fileData = const string fileData =
"# yaml-language-server: $schema=https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/settings-schema.json\n" + "# yaml-language-server: $schema=https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/settings-schema.json\n" +
@ -55,8 +57,10 @@ public class SettingsProvider : ISettingsProvider
"# For the settings file reference guide, visit the link to the wiki below:\n" + "# For the settings file reference guide, visit the link to the wiki below:\n" +
"# https://recyclarr.dev/wiki/yaml/settings-reference/\n"; "# https://recyclarr.dev/wiki/yaml/settings-reference/\n";
_paths.SettingsPath.CreateParentDirectory(); var settingsFile = _paths.AppDataDirectory.File("settings.yml");
using var stream = _paths.SettingsPath.CreateText(); settingsFile.CreateParentDirectory();
using var stream = settingsFile.CreateText();
stream.Write(fileData); stream.Write(fileData);
return settingsFile;
} }
} }

@ -5,9 +5,6 @@ namespace Recyclarr.TrashLib.Startup;
public interface IAppPaths public interface IAppPaths
{ {
IDirectoryInfo AppDataDirectory { get; } IDirectoryInfo AppDataDirectory { get; }
IFileInfo ConfigPath { get; }
IFileInfo SettingsPath { get; }
IFileInfo SecretsPath { get; }
IDirectoryInfo LogDirectory { get; } IDirectoryInfo LogDirectory { get; }
IDirectoryInfo ReposDirectory { get; } IDirectoryInfo ReposDirectory { get; }
IDirectoryInfo CacheDirectory { get; } IDirectoryInfo CacheDirectory { get; }

@ -13,6 +13,7 @@
<PackageReference Include="AutoFixture.NUnit3" PrivateAssets="All" /> <PackageReference Include="AutoFixture.NUnit3" PrivateAssets="All" />
<PackageReference Include="coverlet.collector" PrivateAssets="All" /> <PackageReference Include="coverlet.collector" PrivateAssets="All" />
<PackageReference Include="FluentAssertions" PrivateAssets="All" /> <PackageReference Include="FluentAssertions" PrivateAssets="All" />
<PackageReference Include="FluentAssertions.Analyzers" PrivateAssets="All" />
<PackageReference Include="FluentAssertions.Json" PrivateAssets="All" /> <PackageReference Include="FluentAssertions.Json" PrivateAssets="All" />
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="All" /> <PackageReference Include="GitHubActionsTestLogger" PrivateAssets="All" />
<PackageReference Include="Microsoft.NET.Test.Sdk" PrivateAssets="All" /> <PackageReference Include="Microsoft.NET.Test.Sdk" PrivateAssets="All" />

@ -26,7 +26,7 @@ public class BaseCommandSetupIntegrationTest : CliIntegrationFixture
{ {
const int maxFiles = 25; const int maxFiles = 25;
Fs.AddFile(Paths.SettingsPath.FullName, new MockFileData($@" Fs.AddFile(Paths.AppDataDirectory.File("settings.yml").FullName, new MockFileData($@"
log_janitor: log_janitor:
max_files: {maxFiles} max_files: {maxFiles}
")); "));

@ -63,7 +63,8 @@ public class QualitySizeGuidePhaseTest
_ = sut.Execute(config); _ = sut.Execute(config);
config.QualityDefinition?.PreferredRatio.Should().Be(decimal.Parse(expectedPreferred)); config.QualityDefinition.Should().NotBeNull();
config.QualityDefinition!.PreferredRatio.Should().Be(decimal.Parse(expectedPreferred));
} }
[Test, AutoMockData] [Test, AutoMockData]
@ -91,8 +92,8 @@ public class QualitySizeGuidePhaseTest
}); });
var result = sut.Execute(config); var result = sut.Execute(config);
result.Should().NotBeNull();
result?.Qualities.Should().BeEquivalentTo(new[] result!.Qualities.Should().BeEquivalentTo(new[]
{ {
new QualitySizeItem("quality1", 0, 100, 50) new QualitySizeItem("quality1", 0, 100, 50)
}, },
@ -127,8 +128,8 @@ public class QualitySizeGuidePhaseTest
}); });
var result = sut.Execute(config); var result = sut.Execute(config);
result.Should().NotBeNull();
result?.Qualities.Should().BeEquivalentTo(new[] result!.Qualities.Should().BeEquivalentTo(new[]
{ {
new QualitySizeItem("quality1", 0, 100, 90) new QualitySizeItem("quality1", 0, 100, 90)
}, },

@ -17,7 +17,7 @@ public class ConfigCreationProcessorTest : CliIntegrationFixture
await sut.Process(null); await sut.Process(null);
var file = Fs.GetFile(Paths.ConfigPath); var file = Fs.GetFile(Paths.AppDataDirectory.File("recyclarr.yml"));
file.Should().NotBeNull(); file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty(); file.Contents.Should().NotBeEmpty();
} }

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.Cli.TestLibrary; using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Settings;
@ -19,7 +20,7 @@ repositories:
clone_url: http://the_url.com clone_url: http://the_url.com
"; ";
Fs.AddFile(Paths.SettingsPath, new MockFileData(yamlData)); Fs.AddFile(Paths.AppDataDirectory.File("settings.yml"), new MockFileData(yamlData));
var settings = sut.Settings; var settings = sut.Settings;

@ -1,4 +1,5 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TestLibrary; using Recyclarr.TestLibrary;
@ -107,4 +108,35 @@ public class FileSystemExtensionsTest
act.Should().Throw<IOException>(); act.Should().Throw<IOException>();
} }
[Test]
public void Return_null_when_no_yaml_files_exist()
{
var fs = new MockFileSystem();
var result = fs.CurrentDirectory().YamlFile("test");
result.Should().BeNull();
}
[TestCase("test.yml")]
[TestCase("test.yaml")]
public void Return_non_null_when_single_yaml_file_exists(string yamlFilename)
{
var fs = new MockFileSystem();
fs.AddEmptyFile(yamlFilename);
var result = fs.CurrentDirectory().YamlFile("test");
result.Should().NotBeNull();
result!.Name.Should().Be(yamlFilename);
}
[Test]
public void Throw_when_both_files_exist()
{
var fs = new MockFileSystem();
fs.AddEmptyFile("test.yml");
fs.AddEmptyFile("test.yaml");
var act = () => fs.CurrentDirectory().YamlFile("test");
act.Should().Throw<ConflictingYamlFilesException>();
}
} }

@ -14,9 +14,9 @@ public class ConfigurationFinderTest
{ {
return new[] return new[]
{ {
paths.ConfigPath, paths.AppDataDirectory.File("recyclarr.yml"),
paths.ConfigsDirectory.File("b.yml"), paths.ConfigsDirectory.File("b.yml"),
paths.ConfigsDirectory.File("c.yml") paths.ConfigsDirectory.File("c.yaml")
}; };
} }
@ -97,11 +97,12 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths, [Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut) ConfigurationFinder sut)
{ {
fs.AddEmptyFile(paths.ConfigPath); var configFile = paths.AppDataDirectory.File("recyclarr.yml");
fs.AddEmptyFile(configFile);
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>()); var result = sut.GetConfigFiles(Array.Empty<IFileInfo>());
result.Should().ContainSingle(x => x.FullName == paths.ConfigPath.FullName); result.Should().ContainSingle(x => x.FullName == configFile.FullName);
} }
[Test, AutoMockData] [Test, AutoMockData]

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config; using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.TestLibrary; using Recyclarr.TrashLib.TestLibrary;
@ -30,7 +31,7 @@ api_key: 95283e6b156c42f3af8a9b16173f876b
secret_rp: 1234567 secret_rp: 1234567
"; ";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml)); Fs.AddFile(Paths.AppDataDirectory.File("secrets.yml").FullName, new MockFileData(secretsYml));
var expected = new[] var expected = new[]
{ {
new new
@ -67,7 +68,7 @@ sonarr:
const string secretsYml = "no_api_key: 95283e6b156c42f3af8a9b16173f876b"; const string secretsYml = "no_api_key: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml)); Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr);
result.Should().BeEmpty(); result.Should().BeEmpty();
@ -120,7 +121,7 @@ sonarr:
const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b"; const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml)); Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr);
result.Should().BeEmpty(); result.Should().BeEmpty();
} }

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Yaml; using Recyclarr.TrashLib.Config.Yaml;
using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Settings;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
@ -16,7 +17,7 @@ public class SettingsPersisterTest
{ {
_ = sut.Settings; _ = sut.Settings;
fileSystem.AllFiles.Should().ContainSingle(paths.SettingsPath.FullName); fileSystem.AllFiles.Should().ContainSingle(paths.AppDataDirectory.File("settings.yml").FullName);
} }
[Test, AutoMockData] [Test, AutoMockData]

Loading…
Cancel
Save