using System.IO.Abstractions; using Recyclarr.Cli.Logging; using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Yaml; using Serilog; using Serilog.Context; using YamlDotNet.Core; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; namespace Recyclarr.Cli.Config; public class ConfigurationLoader : IConfigurationLoader where T : ServiceConfiguration { private readonly ILogger _log; private readonly IDeserializer _deserializer; private readonly IFileSystem _fs; public ConfigurationLoader( ILogger log, IFileSystem fs, IYamlSerializerFactory yamlFactory) { _log = log; _fs = fs; _deserializer = yamlFactory.CreateDeserializer(); } public ICollection LoadMany(IEnumerable configFiles, string configSection) { return configFiles.SelectMany(file => Load(file, configSection)).ToList(); } public ICollection Load(string file, string configSection) { _log.Debug("Loading config file: {File}", file); using var logScope = LogContext.PushProperty(LogProperty.Scope, _fs.Path.GetFileName(file)); try { using var stream = _fs.File.OpenText(file); return LoadFromStream(stream, configSection); } catch (EmptyYamlException) { _log.Warning("Configuration file yielded no usable configuration (is it empty?)"); return Array.Empty(); } catch (YamlException e) { var line = e.Start.Line; switch (e.InnerException) { case InvalidCastException: _log.Error("Incompatible value assigned/used at line {Line}: {Msg}", line, e.InnerException.Message); break; default: _log.Error("Exception at line {Line}: {Msg}", line, e.InnerException?.Message ?? e.Message); break; } } _log.Error("Due to previous exception, this file will be skipped: {File}", file); return Array.Empty(); } public ICollection LoadFromStream(TextReader stream, string requestedSection) { _log.Debug("Loading config section: {Section}", requestedSection); var parser = new Parser(stream); parser.Consume(); if (parser.Current is StreamEnd) { _log.Debug("Skipping this config due to StreamEnd"); throw new EmptyYamlException(); } parser.Consume(); if (parser.Current is DocumentEnd) { _log.Debug("Skipping this config due to DocumentEnd"); throw new EmptyYamlException(); } return ParseAllSections(parser, requestedSection); } private ICollection ParseAllSections(Parser parser, string requestedSection) { var configs = new List(); parser.Consume(); while (parser.TryConsume(out var section)) { if (section.Value == requestedSection) { configs.AddRange(ParseSingleSection(parser)); } else { _log.Debug("Skipping non-matching config section {Section} at line {Line}", section.Value, section.Start.Line); parser.SkipThisAndNestedEvents(); } } // If any config names are null, that means user specified array-style (deprecated) instances. if (configs.Any(x => x.Name is null)) { _log.Warning( "Found array-style list of instances instead of named-style. " + "Array-style lists of Sonarr/Radarr instances are deprecated"); } return configs; } private ICollection ParseSingleSection(Parser parser) { var configs = new List(); switch (parser.Current) { case MappingStart: ParseAndAdd(parser, configs); break; case SequenceStart: ParseAndAdd(parser, configs); break; } return configs; } private void ParseAndAdd(Parser parser, ICollection configs) where TStart : ParsingEvent where TEnd : ParsingEvent { parser.Consume(); while (!parser.TryConsume(out _)) { var lineNumber = parser.Current?.Start.Line; string? instanceName = null; if (parser.TryConsume(out var key)) { instanceName = key.Value; } var newConfig = _deserializer.Deserialize(parser); newConfig.Name = instanceName; newConfig.LineNumber = lineNumber ?? 0; configs.Add(newConfig); } } }