diff --git a/Directory.Packages.props b/Directory.Packages.props
index 03b4ab18..3ea57025 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,6 +14,7 @@
+
@@ -77,4 +78,4 @@
-
+
\ No newline at end of file
diff --git a/Recyclarr.sln b/Recyclarr.sln
index 8d3c5b26..e00e2970 100644
--- a/Recyclarr.sln
+++ b/Recyclarr.sln
@@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Cache", "src\Recy
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Cli.TestLibrary", "tests\Recyclarr.Cli.TestLibrary\Recyclarr.Cli.TestLibrary.csproj", "{7B730BDB-1519-4847-BA23-998AB3750E31}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Logging", "src\Recyclarr.Logging\Recyclarr.Logging.csproj", "{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -155,6 +157,10 @@ Global
{7B730BDB-1519-4847-BA23-998AB3750E31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B730BDB-1519-4847-BA23-998AB3750E31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B730BDB-1519-4847-BA23-998AB3750E31}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Recyclarr.Cli/CompositionRoot.cs b/src/Recyclarr.Cli/CompositionRoot.cs
index f395b08c..96c35f7e 100644
--- a/src/Recyclarr.Cli/CompositionRoot.cs
+++ b/src/Recyclarr.Cli/CompositionRoot.cs
@@ -21,6 +21,7 @@ using Recyclarr.Compatibility;
using Recyclarr.Config;
using Recyclarr.Http;
using Recyclarr.Json;
+using Recyclarr.Logging;
using Recyclarr.Platform;
using Recyclarr.Repo;
using Recyclarr.ServarrApi;
@@ -28,7 +29,6 @@ using Recyclarr.Settings;
using Recyclarr.TrashGuide;
using Recyclarr.VersionControl;
using Recyclarr.Yaml;
-using Serilog.Core;
using Spectre.Console.Cli;
namespace Recyclarr.Cli;
@@ -42,7 +42,7 @@ public static class CompositionRoot
// Needed for Autofac.Extras.Ordering
builder.RegisterSource();
- RegisterLogger(builder);
+ RegisterLogger(builder, thisAssembly);
builder.RegisterModule();
builder.RegisterModule();
@@ -89,12 +89,14 @@ public static class CompositionRoot
.OrderByRegistration();
}
- private static void RegisterLogger(ContainerBuilder builder)
+ private static void RegisterLogger(ContainerBuilder builder, Assembly thisAssembly)
{
+ builder.RegisterAssemblyTypes(thisAssembly)
+ .AssignableTo()
+ .As();
+
+ builder.RegisterModule();
builder.RegisterType();
- builder.RegisterType().SingleInstance();
- builder.RegisterType();
- builder.Register(c => c.Resolve().Create()).As().SingleInstance();
}
private static void CliRegistrations(ContainerBuilder builder)
diff --git a/src/Recyclarr.Cli/Logging/ConsoleLogSinkConfigurator.cs b/src/Recyclarr.Cli/Logging/ConsoleLogSinkConfigurator.cs
new file mode 100644
index 00000000..d451d4b9
--- /dev/null
+++ b/src/Recyclarr.Cli/Logging/ConsoleLogSinkConfigurator.cs
@@ -0,0 +1,23 @@
+using Recyclarr.Logging;
+using Recyclarr.Platform;
+using Serilog.Core;
+using Serilog.Templates;
+using Serilog.Templates.Themes;
+
+namespace Recyclarr.Cli.Logging;
+
+internal class ConsoleLogSinkConfigurator(LoggingLevelSwitch levelSwitch, IEnvironment env) : ILogConfigurator
+{
+ public void Configure(LoggerConfiguration config)
+ {
+ config.WriteTo.Console(BuildExpressionTemplate(), levelSwitch: levelSwitch);
+ }
+
+ private ExpressionTemplate BuildExpressionTemplate()
+ {
+ var template = "[{@l:u3}] " + LogTemplates.Base;
+
+ var raw = !string.IsNullOrEmpty(env.GetEnvironmentVariable("NO_COLOR"));
+ return new ExpressionTemplate(template, theme: raw ? null : TemplateTheme.Code);
+ }
+}
diff --git a/src/Recyclarr.Cli/Logging/FileLogSinkConfigurator.cs b/src/Recyclarr.Cli/Logging/FileLogSinkConfigurator.cs
new file mode 100644
index 00000000..c741228f
--- /dev/null
+++ b/src/Recyclarr.Cli/Logging/FileLogSinkConfigurator.cs
@@ -0,0 +1,40 @@
+using System.IO.Abstractions;
+using Recyclarr.Logging;
+using Recyclarr.Platform;
+using Serilog.Events;
+using Serilog.Templates;
+
+namespace Recyclarr.Cli.Logging;
+
+internal class FileLogSinkConfigurator(IAppPaths paths) : ILogConfigurator
+{
+ public void Configure(LoggerConfiguration config)
+ {
+ var logFilePrefix = $"recyclarr_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
+ var logDir = paths.LogDirectory;
+ var template = BuildExpressionTemplate();
+
+ config
+ .WriteTo.Logger(c => c
+ .MinimumLevel.Debug()
+ .WriteTo.File(template, LogFilePath("debug")))
+ .WriteTo.Logger(c => c
+ .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
+ .WriteTo.File(template, LogFilePath("verbose")));
+
+ return;
+
+ string LogFilePath(string type)
+ {
+ return logDir.File($"{logFilePrefix}.{type}.log").FullName;
+ }
+ }
+
+ private static ExpressionTemplate BuildExpressionTemplate()
+ {
+ var template = "[{@t:HH:mm:ss} {@l:u3}] " + LogTemplates.Base +
+ "{Inspect(@x).StackTrace}";
+
+ return new ExpressionTemplate(template);
+ }
+}
diff --git a/src/Recyclarr.Cli/Logging/LoggerFactory.cs b/src/Recyclarr.Cli/Logging/LoggerFactory.cs
deleted file mode 100644
index c42e94f2..00000000
--- a/src/Recyclarr.Cli/Logging/LoggerFactory.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-using System.IO.Abstractions;
-using Recyclarr.Platform;
-using Serilog.Core;
-using Serilog.Events;
-using Serilog.Templates;
-using Serilog.Templates.Themes;
-
-namespace Recyclarr.Cli.Logging;
-
-public class LoggerFactory(
- IAppPaths paths,
- LoggingLevelSwitch levelSwitch,
- IEnvironment env,
- IEnumerable sinks)
-{
- private static string GetBaseTemplateString()
- {
- var scope = LogProperty.Scope;
-
- return
- $"{{#if {scope} is not null}}{{{scope}}}: {{#end}}" +
- "{@m}";
- }
-
- private ExpressionTemplate GetConsoleTemplate()
- {
- var template = "[{@l:u3}] " + GetBaseTemplateString() +
- "{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n";
-
- var raw = !string.IsNullOrEmpty(env.GetEnvironmentVariable("NO_COLOR"));
- return new ExpressionTemplate(template, theme: raw ? null : TemplateTheme.Code);
- }
-
- private static ExpressionTemplate GetFileTemplate()
- {
- var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString() +
- "{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n" +
- "{Inspect(@x).StackTrace}";
-
- return new ExpressionTemplate(template);
- }
-
- public ILogger Create()
- {
- var logFilePrefix = $"recyclarr_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
- var logDir = paths.LogDirectory;
-
- var config = new LoggerConfiguration()
- .MinimumLevel.Is(LogEventLevel.Verbose)
- .Enrich.FromLogContext()
- .Enrich.With()
- .WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch)
- .WriteTo.Logger(c => c
- .MinimumLevel.Debug()
- .WriteTo.File(GetFileTemplate(), LogFilePath("debug")))
- .WriteTo.Logger(c => c
- .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
- .WriteTo.File(GetFileTemplate(), LogFilePath("verbose")));
-
- foreach (var sink in sinks)
- {
- config.WriteTo.Sink(sink, levelSwitch: levelSwitch);
- }
-
- return config.CreateLogger();
-
- string LogFilePath(string type)
- {
- return logDir.File($"{logFilePrefix}.{type}.log").FullName;
- }
- }
-}
diff --git a/src/Recyclarr.Cli/Recyclarr.Cli.csproj b/src/Recyclarr.Cli/Recyclarr.Cli.csproj
index c6159c82..f92d3b83 100644
--- a/src/Recyclarr.Cli/Recyclarr.Cli.csproj
+++ b/src/Recyclarr.Cli/Recyclarr.Cli.csproj
@@ -30,6 +30,7 @@
+
diff --git a/src/Recyclarr.Logging/ExceptionExtensions.cs b/src/Recyclarr.Logging/ExceptionExtensions.cs
new file mode 100644
index 00000000..2e9d94ad
--- /dev/null
+++ b/src/Recyclarr.Logging/ExceptionExtensions.cs
@@ -0,0 +1,23 @@
+using Recyclarr.Common.Extensions;
+
+namespace Recyclarr.Logging;
+
+public static class ExceptionExtensions
+{
+ public static string FullMessage(this Exception ex)
+ {
+ if (ex is AggregateException aex)
+ {
+ return aex.InnerExceptions.Aggregate("[ ", (total, next) => $"{total}[{next.FullMessage()}] ") + "]";
+ }
+
+ var msg = ex.Message.Replace(", see inner exception.", "").Trim();
+ var innerMsg = ex.InnerException?.FullMessage();
+ if (innerMsg != null && !innerMsg.ContainsIgnoreCase(msg) && !msg.ContainsIgnoreCase(innerMsg))
+ {
+ msg = $"{msg} [ {innerMsg} ]";
+ }
+
+ return msg;
+ }
+}
diff --git a/src/Recyclarr.Cli/Logging/FlurlExceptionSanitizingEnricher.cs b/src/Recyclarr.Logging/FlurlExceptionSanitizingEnricher.cs
similarity index 91%
rename from src/Recyclarr.Cli/Logging/FlurlExceptionSanitizingEnricher.cs
rename to src/Recyclarr.Logging/FlurlExceptionSanitizingEnricher.cs
index 79966350..4e8cc44e 100644
--- a/src/Recyclarr.Cli/Logging/FlurlExceptionSanitizingEnricher.cs
+++ b/src/Recyclarr.Logging/FlurlExceptionSanitizingEnricher.cs
@@ -1,8 +1,7 @@
-using Recyclarr.Http;
using Serilog.Core;
using Serilog.Events;
-namespace Recyclarr.Cli.Logging;
+namespace Recyclarr.Logging;
internal class FlurlExceptionSanitizingEnricher : ILogEventEnricher
{
diff --git a/src/Recyclarr.Logging/ILogConfigurator.cs b/src/Recyclarr.Logging/ILogConfigurator.cs
new file mode 100644
index 00000000..703cef54
--- /dev/null
+++ b/src/Recyclarr.Logging/ILogConfigurator.cs
@@ -0,0 +1,8 @@
+using Serilog;
+
+namespace Recyclarr.Logging;
+
+public interface ILogConfigurator
+{
+ void Configure(LoggerConfiguration config);
+}
diff --git a/src/Recyclarr.Logging/LogProperty.cs b/src/Recyclarr.Logging/LogProperty.cs
new file mode 100644
index 00000000..e0363e55
--- /dev/null
+++ b/src/Recyclarr.Logging/LogProperty.cs
@@ -0,0 +1,6 @@
+namespace Recyclarr.Logging;
+
+public static class LogProperty
+{
+ public static string Scope => nameof(Scope);
+}
diff --git a/src/Recyclarr.Logging/LogTemplates.cs b/src/Recyclarr.Logging/LogTemplates.cs
new file mode 100644
index 00000000..057a9d21
--- /dev/null
+++ b/src/Recyclarr.Logging/LogTemplates.cs
@@ -0,0 +1,16 @@
+namespace Recyclarr.Logging;
+
+public static class LogTemplates
+{
+ public static string Base { get; } = GetBaseTemplateString();
+
+ private static string GetBaseTemplateString()
+ {
+ var scope = LogProperty.Scope;
+
+ return
+ $"{{#if {scope} is not null}}{{{scope}}}: {{#end}}" +
+ "{@m}" +
+ "{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n";
+ }
+}
diff --git a/src/Recyclarr.Logging/LoggerFactory.cs b/src/Recyclarr.Logging/LoggerFactory.cs
new file mode 100644
index 00000000..16f94fef
--- /dev/null
+++ b/src/Recyclarr.Logging/LoggerFactory.cs
@@ -0,0 +1,22 @@
+using Serilog;
+using Serilog.Events;
+
+namespace Recyclarr.Logging;
+
+public class LoggerFactory(IEnumerable configurators)
+{
+ public ILogger Create()
+ {
+ var config = new LoggerConfiguration()
+ .MinimumLevel.Is(LogEventLevel.Verbose)
+ .Enrich.FromLogContext()
+ .Enrich.With();
+
+ foreach (var configurator in configurators)
+ {
+ configurator.Configure(config);
+ }
+
+ return config.CreateLogger();
+ }
+}
diff --git a/src/Recyclarr.Logging/LoggingAutofacModule.cs b/src/Recyclarr.Logging/LoggingAutofacModule.cs
new file mode 100644
index 00000000..45db2b4d
--- /dev/null
+++ b/src/Recyclarr.Logging/LoggingAutofacModule.cs
@@ -0,0 +1,18 @@
+using Autofac;
+using Serilog;
+using Serilog.Core;
+using Module = Autofac.Module;
+
+namespace Recyclarr.Logging;
+
+public class LoggingAutofacModule : Module
+{
+ protected override void Load(ContainerBuilder builder)
+ {
+ base.Load(builder);
+
+ builder.RegisterType().SingleInstance();
+ builder.RegisterType();
+ builder.Register(c => c.Resolve().Create()).As().SingleInstance();
+ }
+}
diff --git a/src/Recyclarr.Logging/Recyclarr.Logging.csproj b/src/Recyclarr.Logging/Recyclarr.Logging.csproj
new file mode 100644
index 00000000..894b2a89
--- /dev/null
+++ b/src/Recyclarr.Logging/Recyclarr.Logging.csproj
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Recyclarr.Logging/Sanitize.cs b/src/Recyclarr.Logging/Sanitize.cs
new file mode 100644
index 00000000..91a8d760
--- /dev/null
+++ b/src/Recyclarr.Logging/Sanitize.cs
@@ -0,0 +1,40 @@
+using System.Text.RegularExpressions;
+using Flurl;
+
+namespace Recyclarr.Logging;
+
+public static partial class Sanitize
+{
+ public static string Message(string message)
+ {
+ // Replace full URLs
+ var result = UrlRegex().Replace(message, SanitizeMatch);
+
+ // There are sometimes parenthetical parts of the message that contain the host but are not
+ // detected as true URLs. Just strip those out completely.
+ return HostRegex().Replace(result, "");
+ }
+
+ public static string ExceptionMessage(Exception exception)
+ {
+ return Message(exception.FullMessage());
+ }
+
+ public static Url Url(Url url)
+ {
+ // Replace hostname for user privacy
+ url.Host = "REDACTED";
+ return url;
+ }
+
+ private static string SanitizeMatch(Match match)
+ {
+ return Url(match.Value).ToString() ?? match.Value;
+ }
+
+ [GeneratedRegex(@"\([-a-zA-Z0-9@:%._+~#=]{1,256}(?::[0-9]+)?\)")]
+ private static partial Regex HostRegex();
+
+ [GeneratedRegex(@"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(:[0-9]+)?\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)")]
+ private static partial Regex UrlRegex();
+}