From f06a866a2f1403ca3bc06523ab202b2d4aea5b2e Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 18:48:43 +0200 Subject: [PATCH] Add Multi Instance Support , Serilog , little Hotfixes --- UmlautAdaptarr/Interfaces/IArrApplication.cs | 10 + .../GlobalInstanceOptions.cs} | 9 +- .../InstanceOptions/LidarrInstanceOptions.cs | 6 + .../InstanceOptions/ReadarrInstanceOptions.cs | 6 + .../InstanceOptions/SonarrInstanceOptions.cs | 6 + .../ArrOptions/LidarrInstanceOptions.cs | 9 - .../ArrOptions/ReadarrInstanceOptions.cs | 9 - .../ArrOptions/SonarrInstanceOptions.cs | 9 - UmlautAdaptarr/Program.cs | 91 ++--- UmlautAdaptarr/Providers/ArrClientBase.cs | 19 +- UmlautAdaptarr/Providers/ArrClientFactory.cs | 17 - UmlautAdaptarr/Providers/LidarrClient.cs | 293 ++++++++------- UmlautAdaptarr/Providers/ReadarrClient.cs | 336 +++++++++--------- UmlautAdaptarr/Providers/SonarrClient.cs | 259 +++++++------- .../Services/ArrSyncBackgroundService.cs | 287 ++++++++------- .../Services/Factory/RrApplicationFactory.cs | 76 ++++ .../Services/SearchItemLookupService.cs | 42 ++- UmlautAdaptarr/UmlautAdaptarr.csproj | 1 + .../Utilities/ApiKeyMaskingEnricher.cs | 69 ++++ UmlautAdaptarr/Utilities/Helper.cs | 10 + .../Utilities/KeyedServiceExtensions.cs | 74 ++++ .../Utilities/ServicesExtensions.cs | 209 +++++++---- UmlautAdaptarr/appsettings.json | 40 ++- 23 files changed, 1132 insertions(+), 755 deletions(-) create mode 100644 UmlautAdaptarr/Interfaces/IArrApplication.cs rename UmlautAdaptarr/Options/ArrOptions/{ArrApplicationBaseOptions.cs => InstanceOptions/GlobalInstanceOptions.cs} (71%) create mode 100644 UmlautAdaptarr/Options/ArrOptions/InstanceOptions/LidarrInstanceOptions.cs create mode 100644 UmlautAdaptarr/Options/ArrOptions/InstanceOptions/ReadarrInstanceOptions.cs create mode 100644 UmlautAdaptarr/Options/ArrOptions/InstanceOptions/SonarrInstanceOptions.cs delete mode 100644 UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs delete mode 100644 UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs delete mode 100644 UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs delete mode 100644 UmlautAdaptarr/Providers/ArrClientFactory.cs create mode 100644 UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs create mode 100644 UmlautAdaptarr/Utilities/ApiKeyMaskingEnricher.cs create mode 100644 UmlautAdaptarr/Utilities/Helper.cs create mode 100644 UmlautAdaptarr/Utilities/KeyedServiceExtensions.cs diff --git a/UmlautAdaptarr/Interfaces/IArrApplication.cs b/UmlautAdaptarr/Interfaces/IArrApplication.cs new file mode 100644 index 0000000..c678fa6 --- /dev/null +++ b/UmlautAdaptarr/Interfaces/IArrApplication.cs @@ -0,0 +1,10 @@ +using UmlautAdaptarr.Models; + +namespace UmlautAdaptarr.Interfaces; + +public interface IArrApplication +{ + Task> FetchAllItemsAsync(); + Task FetchItemByExternalIdAsync(string externalId); + Task FetchItemByTitleAsync(string title); +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs similarity index 71% rename from UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs rename to UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs index dc7df56..e41c492 100644 --- a/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs @@ -1,9 +1,6 @@ -namespace UmlautAdaptarr.Options.ArrOptions +namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions { - /// - /// Base Options for ARR applications - /// - public class ArrApplicationBaseOptions + public class GlobalInstanceOptions { /// /// Indicates whether the Arr application is enabled. @@ -20,4 +17,4 @@ /// public string ApiKey { get; set; } } -} \ No newline at end of file +} diff --git a/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/LidarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/LidarrInstanceOptions.cs new file mode 100644 index 0000000..3cb923c --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/LidarrInstanceOptions.cs @@ -0,0 +1,6 @@ +namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions; + +public class LidarrInstanceOptions : GlobalInstanceOptions +{ + +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/ReadarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/ReadarrInstanceOptions.cs new file mode 100644 index 0000000..3450319 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/ReadarrInstanceOptions.cs @@ -0,0 +1,6 @@ +namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions; + +public class ReadarrInstanceOptions : GlobalInstanceOptions +{ + +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/SonarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/SonarrInstanceOptions.cs new file mode 100644 index 0000000..1d07b18 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/SonarrInstanceOptions.cs @@ -0,0 +1,6 @@ +namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions; + +public class SonarrInstanceOptions : GlobalInstanceOptions +{ + +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs deleted file mode 100644 index d5c85e2..0000000 --- a/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace UmlautAdaptarr.Options.ArrOptions -{ - /// - /// Lidarr Options - /// - public class LidarrInstanceOptions : ArrApplicationBaseOptions - { - } -} diff --git a/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs deleted file mode 100644 index 530e428..0000000 --- a/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace UmlautAdaptarr.Options.ArrOptions -{ - /// - /// Readarr Options - /// - public class ReadarrInstanceOptions : ArrApplicationBaseOptions - { - } -} diff --git a/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs deleted file mode 100644 index cacc6c3..0000000 --- a/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace UmlautAdaptarr.Options.ArrOptions -{ - /// - /// Sonarr Options - /// - public class SonarrInstanceOptions : ArrApplicationBaseOptions - { - } -} diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index b1b4eae..eb4479f 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,27 +1,45 @@ using System.Net; +using Serilog; +using Serilog.Filters; using UmlautAdaptarr.Options; using UmlautAdaptarr.Routing; using UmlautAdaptarr.Services; +using UmlautAdaptarr.Services.Factory; using UmlautAdaptarr.Utilities; internal class Program { private static void Main(string[] args) { + + + Helper.ShowLogo(); + // TODO: // add option to sort by nzb age - - - var builder = WebApplication.CreateBuilder(args); - + var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; + // TODO workaround to not log api keys + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .WriteTo.Console(outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .Filter.ByExcluding(Matching.FromSource("System.Net.Http.HttpClient")) + .Filter.ByExcluding(Matching.FromSource("Microsoft.Extensions.Http.DefaultHttpClientFactory")) + //.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // Not Work currently + .CreateLogger(); + + + + builder.Services.AddSerilog(); + // Add services to the container. builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() => { var handler = new HttpClientHandler { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | + DecompressionMethods.Brotli }; var proxyOptions = configuration.GetSection("Proxy").Get(); @@ -36,19 +54,7 @@ internal class Program }); - // TODO workaround to not log api keys - builder.Logging.AddFilter((category, level) => - { - // Prevent logging of HTTP request and response if the category is HttpClient - if (category.Contains("System.Net.Http.HttpClient") || category.Contains("Microsoft.Extensions.Http.DefaultHttpClientFactory")) - { - return false; - } - return true; - }); - builder.Services.AddControllers(); - builder.Services.AddHostedService(); builder.AddTitleLookupService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -57,6 +63,8 @@ internal class Program builder.AddReadarrSupport(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(); var app = builder.Build(); @@ -65,36 +73,35 @@ internal class Program app.UseHttpsRedirection(); app.UseAuthorization(); - app.MapControllerRoute(name: "caps", - pattern: "{options}/{*domain}", - defaults: new { controller = "Caps", action = "Caps" }, - constraints: new { t = new TRouteConstraint("caps") }); + app.MapControllerRoute("caps", + "{options}/{*domain}", + new { controller = "Caps", action = "Caps" }, + new { t = new TRouteConstraint("caps") }); - app.MapControllerRoute(name: "movie-search", - pattern: "{options}/{*domain}", - defaults: new { controller = "Search", action = "MovieSearch" }, - constraints: new { t = new TRouteConstraint("movie") }); + app.MapControllerRoute("movie-search", + "{options}/{*domain}", + new { controller = "Search", action = "MovieSearch" }, + new { t = new TRouteConstraint("movie") }); - app.MapControllerRoute(name: "tv-search", - pattern: "{options}/{*domain}", - defaults: new { controller = "Search", action = "TVSearch" }, - constraints: new { t = new TRouteConstraint("tvsearch") }); + app.MapControllerRoute("tv-search", + "{options}/{*domain}", + new { controller = "Search", action = "TVSearch" }, + new { t = new TRouteConstraint("tvsearch") }); - app.MapControllerRoute(name: "music-search", - pattern: "{options}/{*domain}", - defaults: new { controller = "Search", action = "MusicSearch" }, - constraints: new { t = new TRouteConstraint("music") }); + app.MapControllerRoute("music-search", + "{options}/{*domain}", + new { controller = "Search", action = "MusicSearch" }, + new { t = new TRouteConstraint("music") }); - app.MapControllerRoute(name: "book-search", - pattern: "{options}/{*domain}", - defaults: new { controller = "Search", action = "BookSearch" }, - constraints: new { t = new TRouteConstraint("book") }); - - app.MapControllerRoute(name: "generic-search", - pattern: "{options}/{*domain}", - defaults: new { controller = "Search", action = "GenericSearch" }, - constraints: new { t = new TRouteConstraint("search") }); + app.MapControllerRoute("book-search", + "{options}/{*domain}", + new { controller = "Search", action = "BookSearch" }, + new { t = new TRouteConstraint("book") }); + app.MapControllerRoute("generic-search", + "{options}/{*domain}", + new { controller = "Search", action = "GenericSearch" }, + new { t = new TRouteConstraint("search") }); app.Run(); } } \ No newline at end of file diff --git a/UmlautAdaptarr/Providers/ArrClientBase.cs b/UmlautAdaptarr/Providers/ArrClientBase.cs index 80a89ce..e192fae 100644 --- a/UmlautAdaptarr/Providers/ArrClientBase.cs +++ b/UmlautAdaptarr/Providers/ArrClientBase.cs @@ -1,13 +1,12 @@ -using Microsoft.Extensions.Caching.Memory; +using UmlautAdaptarr.Interfaces; using UmlautAdaptarr.Models; -using UmlautAdaptarr.Services; -namespace UmlautAdaptarr.Providers +namespace UmlautAdaptarr.Providers; + +public abstract class ArrClientBase : IArrApplication { - public abstract class ArrClientBase() - { - public abstract Task> FetchAllItemsAsync(); - public abstract Task FetchItemByExternalIdAsync(string externalId); - public abstract Task FetchItemByTitleAsync(string title); - } -} + public string InstanceName; + public abstract Task> FetchAllItemsAsync(); + public abstract Task FetchItemByExternalIdAsync(string externalId); + public abstract Task FetchItemByTitleAsync(string title); +} \ No newline at end of file diff --git a/UmlautAdaptarr/Providers/ArrClientFactory.cs b/UmlautAdaptarr/Providers/ArrClientFactory.cs deleted file mode 100644 index 68f92e5..0000000 --- a/UmlautAdaptarr/Providers/ArrClientFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace UmlautAdaptarr.Providers -{ - public static class ArrClientFactory - { - // TODO, still uses old IConfiguration - // TODO not used yet - public static IEnumerable CreateClients( - Func constructor, IConfiguration configuration, string configKey) where TClient : ArrClientBase - { - var hosts = configuration.GetValue(configKey)?.Split(',') ?? throw new ArgumentException($"{configKey} environment variable must be set if the app is enabled"); - foreach (var host in hosts) - { - yield return constructor(host.Trim()); - } - } - } -} \ No newline at end of file diff --git a/UmlautAdaptarr/Providers/LidarrClient.cs b/UmlautAdaptarr/Providers/LidarrClient.cs index cde2677..dc2d3ce 100644 --- a/UmlautAdaptarr/Providers/LidarrClient.cs +++ b/UmlautAdaptarr/Providers/LidarrClient.cs @@ -1,149 +1,168 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using UmlautAdaptarr.Models; -using UmlautAdaptarr.Options.ArrOptions; +using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; -namespace UmlautAdaptarr.Providers +namespace UmlautAdaptarr.Providers; + +public class LidarrClient : ArrClientBase { - public class LidarrClient( + private readonly IMemoryCache _cache; + private readonly CacheService _cacheService; + private readonly IHttpClientFactory _clientFactory; + private readonly ILogger _logger; + private readonly string _mediaType = "audio"; + + public LidarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory, CacheService cacheService, - IMemoryCache cache, - ILogger logger, IOptions options) : ArrClientBase() + IMemoryCache cache, IOptionsMonitor options, + ILogger logger) { - public LidarrInstanceOptions LidarrOptions { get; } = options.Value; - private readonly string _mediaType = "audio"; - - public override async Task> FetchAllItemsAsync() - { - var httpClient = clientFactory.CreateClient(); - var items = new List(); - - try - { - var lidarrArtistsUrl = $"{LidarrOptions.Host}/api/v1/artist?apikey={LidarrOptions.ApiKey}"; - logger.LogInformation($"Fetching all artists from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); - var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl); - var artists = JsonConvert.DeserializeObject>(artistsApiResponse); - - if (artists == null) - { - logger.LogError($"Lidarr artists API request resulted in null"); - return items; - } - logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr."); - foreach (var artist in artists) - { - var artistId = (int)artist.id; - - var lidarrAlbumUrl = $"{LidarrOptions.Host}/api/v1/album?artistId={artistId}&apikey={LidarrOptions.ApiKey}"; - - // TODO add caching here - // Disable cache for now as it can result in problems when adding new albums that aren't displayed on the artists page initially - //if (cache.TryGetValue(lidarrAlbumUrl, out List? albums)) - //{ - // logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); - //} - //else - //{ - logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); - var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl); - var albums = JsonConvert.DeserializeObject>(albumApiResponse); - //} - - if (albums == null) - { - logger.LogWarning($"Lidarr album API request for artistId {artistId} resulted in null"); - continue; - } - - logger.LogInformation($"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr."); - - // Cache albums for 3 minutes - cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3)); - - foreach (var album in albums) - { - var artistName = (string)album.artist.artistName; - var albumTitle = (string)album.title; - - var expectedTitle = $"{artistName} {albumTitle}"; - - string[]? aliases = null; - - // Abuse externalId to set the search string Lidarr uses - var externalId = expectedTitle.GetLidarrTitleForExternalId(); - - var searchItem = new SearchItem - ( - arrId: artistId, - externalId: externalId, - title: albumTitle, - expectedTitle: albumTitle, - germanTitle: null, - aliases: aliases, - mediaType: _mediaType, - expectedAuthor: artistName - ); - - items.Add(searchItem); - } - } - - logger.LogInformation($"Finished fetching all items from Lidarr"); - } - catch (Exception ex) - { - logger.LogError($"Error fetching all artists from Lidarr: {ex.Message}"); - } - - return items; - } - - public override async Task FetchItemByExternalIdAsync(string externalId) - { - try - { - // For now we have to fetch all items every time - // TODO if possible look at the author in search query and only update for author - var searchItems = await FetchAllItemsAsync(); - foreach (var searchItem in searchItems ?? []) - { - try - { - cacheService.CacheSearchItem(searchItem); - } - catch (Exception ex) - { - logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); - } - } - } - catch (Exception ex) - { - logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}"); - } - - return null; - } - - public override async Task FetchItemByTitleAsync(string title) - { - try - { - // this should never be called at the moment - throw new NotImplementedException(); - } - catch (Exception ex) - { - logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}"); - } - - return null; - } + _clientFactory = clientFactory; + _cacheService = cacheService; + _cache = cache; + _logger = logger; + InstanceName = instanceName; + Options = options.Get(InstanceName); + _logger.LogInformation($"Init Lidarr ({InstanceName})"); } -} + + public LidarrInstanceOptions Options { get; init; } + + + public override async Task> FetchAllItemsAsync() + { + var httpClient = _clientFactory.CreateClient(); + var items = new List(); + + try + { + var lidarrArtistsUrl = $"{Options.Host}/api/v1/artist?apikey={Options.ApiKey}"; + _logger.LogInformation( + $"Fetching all artists from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); + var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl); + var artists = JsonConvert.DeserializeObject>(artistsApiResponse); + + if (artists == null) + { + _logger.LogError($"Lidarr ({InstanceName}) artists API request resulted in null"); + return items; + } + + _logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr ({InstanceName})."); + foreach (var artist in artists) + { + var artistId = (int)artist.id; + + var lidarrAlbumUrl = $"{Options.Host}/api/v1/album?artistId={artistId}&apikey={Options.ApiKey}"; + + // TODO add caching here + // Disable cache for now as it can result in problems when adding new albums that aren't displayed on the artists page initially + //if (cache.TryGetValue(lidarrAlbumUrl, out List? albums)) + //{ + // logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); + //} + //else + //{ + _logger.LogInformation( + $"Fetching all albums from artistId {artistId} from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); + var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl); + var albums = JsonConvert.DeserializeObject>(albumApiResponse); + //} + + if (albums == null) + { + _logger.LogWarning( + $"Lidarr ({InstanceName}) album API request for artistId {artistId} resulted in null"); + continue; + } + + _logger.LogInformation( + $"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr ({InstanceName})."); + + // Cache albums for 3 minutes + _cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3)); + + foreach (var album in albums) + { + var artistName = (string)album.artist.artistName; + var albumTitle = (string)album.title; + + var expectedTitle = $"{artistName} {albumTitle}"; + + string[]? aliases = null; + + // Abuse externalId to set the search string Lidarr uses + var externalId = expectedTitle.GetLidarrTitleForExternalId(); + + var searchItem = new SearchItem + ( + artistId, + externalId, + albumTitle, + albumTitle, + null, + aliases: aliases, + mediaType: _mediaType, + expectedAuthor: artistName + ); + + items.Add(searchItem); + } + } + + _logger.LogInformation($"Finished fetching all items from Lidarr ({InstanceName})"); + } + catch (Exception ex) + { + _logger.LogError($"Error fetching all artists from Lidarr ({InstanceName}) : {ex.Message}"); + } + + return items; + } + + public override async Task FetchItemByExternalIdAsync(string externalId) + { + try + { + // For now we have to fetch all items every time + // TODO if possible look at the author in search query and only update for author + var searchItems = await FetchAllItemsAsync(); + foreach (var searchItem in searchItems ?? []) + try + { + _cacheService.CacheSearchItem(searchItem); + } + catch (Exception ex) + { + _logger.LogError(ex, + $"An error occurred while caching search item with ID {searchItem.ArrId} in Lidarr ({InstanceName})."); + } + } + catch (Exception ex) + { + _logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}) : {ex.Message}"); + } + + return null; + } + + public override async Task FetchItemByTitleAsync(string title) + { + try + { + // this should never be called at the moment + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}): {ex.Message}"); + } + + return null; + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Providers/ReadarrClient.cs b/UmlautAdaptarr/Providers/ReadarrClient.cs index ce4a772..e29db88 100644 --- a/UmlautAdaptarr/Providers/ReadarrClient.cs +++ b/UmlautAdaptarr/Providers/ReadarrClient.cs @@ -1,174 +1,186 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using UmlautAdaptarr.Models; -using UmlautAdaptarr.Options.ArrOptions; +using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; -namespace UmlautAdaptarr.Providers +namespace UmlautAdaptarr.Providers; + +public class ReadarrClient : ArrClientBase { - public class ReadarrClient( - IHttpClientFactory clientFactory, + private readonly IMemoryCache _cache; + private readonly CacheService _cacheService; + private readonly IHttpClientFactory _clientFactory; + private readonly ILogger _logger; + private readonly string _mediaType = "book"; + + public ReadarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory, CacheService cacheService, IMemoryCache cache, - IOptions options, - ILogger logger) : ArrClientBase() + IOptionsMonitor options, + ILogger logger) { - - public ReadarrInstanceOptions ReadarrOptions { get; } = options.Value; - private readonly string _mediaType = "book"; - - public override async Task> FetchAllItemsAsync() - { - var httpClient = clientFactory.CreateClient(); - var items = new List(); - - try - { - var readarrAuthorUrl = $"{ReadarrOptions.Host}/api/v1/author?apikey={ReadarrOptions.ApiKey}"; - logger.LogInformation($"Fetching all authors from Readarr: {UrlUtilities.RedactApiKey(readarrAuthorUrl)}"); - var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl); - var authors = JsonConvert.DeserializeObject>(authorApiResponse); - - if (authors == null) - { - logger.LogError($"Readarr authors API request resulted in null"); - return items; - } - logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr."); - foreach (var author in authors) - { - var authorId = (int)author.id; - - var readarrBookUrl = $"{ReadarrOptions.Host}/api/v1/book?authorId={authorId}&apikey={ReadarrOptions.ApiKey}"; - - // TODO add caching here - logger.LogInformation($"Fetching all books from authorId {authorId} from Readarr: {UrlUtilities.RedactApiKey(readarrBookUrl)}"); - var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl); - var books = JsonConvert.DeserializeObject>(bookApiResponse); - - if (books == null) - { - logger.LogWarning($"Readarr book API request for authorId {authorId} resulted in null"); - continue; - } - - logger.LogInformation($"Successfully fetched {books.Count} books for authorId {authorId} from Readarr."); - - // Cache books for 3 minutes - cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3)); - - foreach (var book in books) - { - var authorName = (string)author.authorName; - var bookTitle = GetSearchBookTitle((string)book.title, authorName); - - var expectedTitle = $"{bookTitle} {authorName}"; - - string[]? aliases = null; - - // Abuse externalId to set the search string Readarr uses - // TODO use own method or rename - var externalId = expectedTitle.GetReadarrTitleForExternalId(); - - var searchItem = new SearchItem - ( - arrId: authorId, - externalId: externalId, - title: bookTitle, - expectedTitle: bookTitle, - germanTitle: null, - aliases: aliases, - mediaType: _mediaType, - expectedAuthor: authorName - ); - - items.Add(searchItem); - } - } - - logger.LogInformation($"Finished fetching all items from Readarr"); - } - catch (Exception ex) - { - logger.LogError($"Error fetching all authors from Readarr: {ex.Message}"); - } - - return items; - } - - // Logic based on https://github.com/Readarr/Readarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs#L541 - public static string GetSearchBookTitle(string bookTitle, string authorName) - { - // Remove author prefix from title if present, e.g., "Tom Clancy: Ghost Protocol" - if (!string.IsNullOrEmpty(authorName) && bookTitle.StartsWith($"{authorName}:")) - { - bookTitle = bookTitle[(authorName.Length + 1)..].Trim(); - } - - // Remove subtitles or additional info enclosed in parentheses or following a colon, if any - int firstParenthesisIndex = bookTitle.IndexOf('('); - int firstColonIndex = bookTitle.IndexOf(':'); - - if (firstParenthesisIndex > -1) - { - int endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex); - if (endParenthesisIndex > -1 && bookTitle.Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1).Contains(' ')) - { - bookTitle = bookTitle[..firstParenthesisIndex].Trim(); - } - } - else if (firstColonIndex > -1) - { - bookTitle = bookTitle[..firstColonIndex].Trim(); - } - - return bookTitle; - } - - - public override async Task FetchItemByExternalIdAsync(string externalId) - { - try - { - // For now we have to fetch all items every time - // TODO if possible look at the author in search query and only update for author - var searchItems = await FetchAllItemsAsync(); - foreach (var searchItem in searchItems ?? []) - { - try - { - cacheService.CacheSearchItem(searchItem); - } - catch (Exception ex) - { - logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); - } - } - } - catch (Exception ex) - { - logger.LogError($"Error fetching single author from Readarr: {ex.Message}"); - } - - return null; - } - - public override async Task FetchItemByTitleAsync(string title) - { - try - { - // this should never be called at the moment - throw new NotImplementedException(); - } - catch (Exception ex) - { - logger.LogError($"Error fetching single author from Readarr: {ex.Message}"); - } - - return null; - } + _clientFactory = clientFactory; + _cacheService = cacheService; + _cache = cache; + _logger = logger; + InstanceName = instanceName; + Options = options.Get(InstanceName); + _logger.LogInformation($"Init ReadarrClient ({InstanceName})"); } -} + + public ReadarrInstanceOptions Options { get; init; } + + public override async Task> FetchAllItemsAsync() + { + var httpClient = _clientFactory.CreateClient(); + var items = new List(); + + try + { + var readarrAuthorUrl = $"{Options.Host}/api/v1/author?apikey={Options.ApiKey}"; + _logger.LogInformation( + $"Fetching all authors from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrAuthorUrl)}"); + var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl); + var authors = JsonConvert.DeserializeObject>(authorApiResponse); + + if (authors == null) + { + _logger.LogError($"Readarr ({InstanceName}) authors API request resulted in null"); + return items; + } + + _logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr ({InstanceName})."); + foreach (var author in authors) + { + var authorId = (int)author.id; + + var readarrBookUrl = $"{Options.Host}/api/v1/book?authorId={authorId}&apikey={Options.ApiKey}"; + + // TODO add caching here + _logger.LogInformation( + $"Fetching all books from authorId {authorId} from Readarr: {UrlUtilities.RedactApiKey(readarrBookUrl)}"); + var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl); + var books = JsonConvert.DeserializeObject>(bookApiResponse); + + if (books == null) + { + _logger.LogWarning( + $"Readarr ({InstanceName}) book API request for authorId {authorId} resulted in null"); + continue; + } + + _logger.LogInformation( + $"Successfully fetched {books.Count} books for authorId {authorId} from Readarr ({InstanceName}) ."); + + // Cache books for 3 minutes + _cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3)); + + foreach (var book in books) + { + var authorName = (string)author.authorName; + var bookTitle = GetSearchBookTitle((string)book.title, authorName); + + var expectedTitle = $"{bookTitle} {authorName}"; + + string[]? aliases = null; + + // Abuse externalId to set the search string Readarr uses + // TODO use own method or rename + var externalId = expectedTitle.GetReadarrTitleForExternalId(); + + var searchItem = new SearchItem + ( + authorId, + externalId, + bookTitle, + bookTitle, + null, + aliases: aliases, + mediaType: _mediaType, + expectedAuthor: authorName + ); + + items.Add(searchItem); + } + } + + _logger.LogInformation($"Finished fetching all items from Readarr ({InstanceName})"); + } + catch (Exception ex) + { + _logger.LogError($"Error fetching all authors from Readarr ({InstanceName}): {ex.Message}"); + } + + return items; + } + + // Logic based on https://github.com/Readarr/Readarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs#L541 + public static string GetSearchBookTitle(string bookTitle, string authorName) + { + // Remove author prefix from title if present, e.g., "Tom Clancy: Ghost Protocol" + if (!string.IsNullOrEmpty(authorName) && bookTitle.StartsWith($"{authorName}:")) + bookTitle = bookTitle[(authorName.Length + 1)..].Trim(); + + // Remove subtitles or additional info enclosed in parentheses or following a colon, if any + var firstParenthesisIndex = bookTitle.IndexOf('('); + var firstColonIndex = bookTitle.IndexOf(':'); + + if (firstParenthesisIndex > -1) + { + var endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex); + if (endParenthesisIndex > -1 && bookTitle + .Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1) + .Contains(' ')) bookTitle = bookTitle[..firstParenthesisIndex].Trim(); + } + else if (firstColonIndex > -1) + { + bookTitle = bookTitle[..firstColonIndex].Trim(); + } + + return bookTitle; + } + + + public override async Task FetchItemByExternalIdAsync(string externalId) + { + try + { + // For now we have to fetch all items every time + // TODO if possible look at the author in search query and only update for author + var searchItems = await FetchAllItemsAsync(); + foreach (var searchItem in searchItems ?? []) + try + { + _cacheService.CacheSearchItem(searchItem); + } + catch (Exception ex) + { + _logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); + } + } + catch (Exception ex) + { + _logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}"); + } + + return null; + } + + public override async Task FetchItemByTitleAsync(string title) + { + try + { + // this should never be called at the moment + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}"); + } + + return null; + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index b9d9ef6..9d9b0ad 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -1,171 +1,192 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using UmlautAdaptarr.Models; -using UmlautAdaptarr.Options.ArrOptions; +using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; -namespace UmlautAdaptarr.Providers +namespace UmlautAdaptarr.Providers; + +public class SonarrClient : ArrClientBase { - public class SonarrClient( + private readonly IHttpClientFactory _clientFactory; + private readonly ILogger _logger; + + private readonly string _mediaType = "tv"; + private readonly TitleApiService _titleService; + + + public SonarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory, TitleApiService titleService, - IOptions options, - ILogger logger) : ArrClientBase() + IOptionsMonitor options, + ILogger logger) { - public SonarrInstanceOptions SonarrOptions { get; } = options.Value; - private readonly string _mediaType = "tv"; + _clientFactory = clientFactory; + _titleService = titleService; + _logger = logger; - public override async Task> FetchAllItemsAsync() + InstanceName = instanceName; + Options = options.Get(InstanceName); + _logger.LogInformation($"Init SonarrClient ({InstanceName})"); + } + + public SonarrInstanceOptions Options { get; init; } + + public override async Task> FetchAllItemsAsync() + { + var httpClient = _clientFactory.CreateClient(); + var items = new List(); + + try { - var httpClient = clientFactory.CreateClient(); - var items = new List(); + var sonarrUrl = $"{Options.Host}/api/v3/series?includeSeasonImages=false&apikey={Options.ApiKey}"; + _logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); + var response = await httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject>(response); - try + if (shows != null) { - var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?includeSeasonImages=false&apikey={SonarrOptions.ApiKey}"; - logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); - var response = await httpClient.GetStringAsync(sonarrUrl); - var shows = JsonConvert.DeserializeObject>(response); - - if (shows != null) - { - logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr."); - foreach (var show in shows) - { - var tvdbId = (string)show.tvdbId; - if (tvdbId == null) - { - logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId."); - continue; - } - - (var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); - var searchItem = new SearchItem - ( - arrId: (int)show.id, - externalId: tvdbId, - title: (string)show.title, - expectedTitle: (string)show.title, - germanTitle: germanTitle, - aliases: aliases, - mediaType: _mediaType - ); - - items.Add(searchItem); - } - } - - logger.LogInformation($"Finished fetching all items from Sonarr"); - } - catch (Exception ex) - { - logger.LogError($"Error fetching all shows from Sonarr: {ex.Message}"); - } - - return items; - } - - public override async Task FetchItemByExternalIdAsync(string externalId) - { - var httpClient = clientFactory.CreateClient(); - - try - { - var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}"; - logger.LogInformation($"Fetching item by external ID from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); - var response = await httpClient.GetStringAsync(sonarrUrl); - var shows = JsonConvert.DeserializeObject(response); - var show = shows?[0]; - - if (show != null) + _logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName})."); + foreach (var show in shows) { var tvdbId = (string)show.tvdbId; if (tvdbId == null) { - logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId."); - return null; + _logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId."); + continue; } - (var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); - + + var (germanTitle, aliases) = + await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); var searchItem = new SearchItem ( - arrId: (int)show.id, - externalId: tvdbId, - title: (string)show.title, - expectedTitle: (string)show.title, - germanTitle: germanTitle, + (int)show.id, + tvdbId, + (string)show.title, + (string)show.title, + germanTitle, aliases: aliases, mediaType: _mediaType ); - logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr."); - return searchItem; + items.Add(searchItem); } } - catch (Exception ex) - { - logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); - } - return null; + _logger.LogInformation($"Finished fetching all items from Sonarr ({InstanceName})"); + } + catch (Exception ex) + { + _logger.LogError($"Error fetching all shows from Sonarr ({InstanceName}) : {ex.Message}"); } - public override async Task FetchItemByTitleAsync(string title) + return items; + } + + public override async Task FetchItemByExternalIdAsync(string externalId) + { + var httpClient = _clientFactory.CreateClient(); + + try { - var httpClient = clientFactory.CreateClient(); + var sonarrUrl = + $"{Options.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={Options.ApiKey}"; + _logger.LogInformation( + $"Fetching item by external ID from Sonarr ({InstanceName}): {UrlUtilities.RedactApiKey(sonarrUrl)}"); + var response = await httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(response); + var show = shows?[0]; - try + if (show != null) { - (string? germanTitle, string? tvdbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title); - + var tvdbId = (string)show.tvdbId; if (tvdbId == null) { + _logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId."); return null; } - var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}"; - var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl); - var shows = JsonConvert.DeserializeObject(sonarrApiResponse); - - if (shows == null) - { - logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); - return null; - } - else if (shows.Count == 0) - { - logger.LogWarning($"No results found for TVDB ID {tvdbId}"); - return null; - } - - var expectedTitle = (string)shows[0].title; - if (expectedTitle == null) - { - logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); - return null; - } + var (germanTitle, aliases) = + await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); var searchItem = new SearchItem ( - arrId: (int)shows[0].id, - externalId: tvdbId, - title: (string)shows[0].title, - expectedTitle: (string)shows[0].title, - germanTitle: germanTitle, + (int)show.id, + tvdbId, + (string)show.title, + (string)show.title, + germanTitle, aliases: aliases, mediaType: _mediaType ); - logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr."); + _logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName})."); return searchItem; } - catch (Exception ex) + } + catch (Exception ex) + { + _logger.LogError($"Error fetching single show from Sonarr ({InstanceName}): {ex.Message}"); + } + + return null; + } + + public override async Task FetchItemByTitleAsync(string title) + { + var httpClient = _clientFactory.CreateClient(); + + try + { + var (germanTitle, tvdbId, aliases) = + await _titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title); + + if (tvdbId == null) return null; + + var sonarrUrl = + $"{Options.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={Options.ApiKey}"; + var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(sonarrApiResponse); + + if (shows == null) { - logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); + _logger.LogError($"Parsing Sonarr ({InstanceName}) API response for TVDB ID {tvdbId} resulted in null"); + return null; } - return null; + if (shows.Count == 0) + { + _logger.LogWarning($"No results found for TVDB ID {tvdbId}"); + return null; + } + + var expectedTitle = (string)shows[0].title; + if (expectedTitle == null) + { + _logger.LogError($"Sonarr ({InstanceName}) : Title for TVDB ID {tvdbId} is null"); + return null; + } + + var searchItem = new SearchItem + ( + (int)shows[0].id, + tvdbId, + (string)shows[0].title, + (string)shows[0].title, + germanTitle, + aliases: aliases, + mediaType: _mediaType + ); + + _logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName})."); + return searchItem; } + catch (Exception ex) + { + _logger.LogError($"Error fetching single show from Sonarr ({InstanceName}) : {ex.Message}"); + } + + return null; } -} +} \ No newline at end of file diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs index 00fedfb..181e091 100644 --- a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -1,145 +1,170 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System; -using System.Threading; -using System.Threading.Tasks; -using UmlautAdaptarr.Models; -using UmlautAdaptarr.Providers; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Services.Factory; -namespace UmlautAdaptarr.Services +namespace UmlautAdaptarr.Services; + +public class ArrSyncBackgroundService( + RrApplicationFactory rrApplicationFactory, + CacheService cacheService, + ILogger logger) + : BackgroundService { - public class ArrSyncBackgroundService( - SonarrClient sonarrClient, - LidarrClient lidarrClient, - ReadarrClient readarrClient, - CacheService cacheService, - ILogger logger) : BackgroundService + public RrApplicationFactory RrApplicationFactory { get; } = rrApplicationFactory; + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + logger.LogInformation("ArrSyncBackgroundService is starting."); + var lastRunSuccess = true; + + while (!stoppingToken.IsCancellationRequested) { - logger.LogInformation("ArrSyncBackgroundService is starting."); - bool lastRunSuccess = true; + logger.LogInformation("ArrSyncBackgroundService is running."); + var syncSuccess = await FetchAndUpdateDataAsync(); + logger.LogInformation("ArrSyncBackgroundService has completed an iteration."); - while (!stoppingToken.IsCancellationRequested) + if (syncSuccess) { - logger.LogInformation("ArrSyncBackgroundService is running."); - var syncSuccess = await FetchAndUpdateDataAsync(); - logger.LogInformation("ArrSyncBackgroundService has completed an iteration."); - - if (syncSuccess) + lastRunSuccess = true; + await Task.Delay(TimeSpan.FromHours(12), stoppingToken); + } + else + { + if (lastRunSuccess) { - lastRunSuccess = true; - await Task.Delay(TimeSpan.FromHours(12), stoppingToken); + lastRunSuccess = false; + logger.LogInformation( + "ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful."); + await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } else { - if (lastRunSuccess) - { - lastRunSuccess = false; - logger.LogInformation("ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful."); - await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); - } - else - { - logger.LogInformation("ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row."); - await Task.Delay(TimeSpan.FromHours(1), stoppingToken); - } - } - } - - logger.LogInformation("ArrSyncBackgroundService is stopping."); - } - - private async Task FetchAndUpdateDataAsync() - { - try - { - var success = true; - if (readarrClient.ReadarrOptions.Enabled) - { - var syncSuccess = await FetchItemsFromReadarrAsync(); - success = success && syncSuccess; - } - if (sonarrClient.SonarrOptions.Enabled) - { - var syncSuccess = await FetchItemsFromSonarrAsync(); - success = success && syncSuccess; - } - if (lidarrClient.LidarrOptions.Enabled) - { - var syncSuccess = await FetchItemsFromLidarrAsync(); - success = success && syncSuccess; - } - return success; - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while fetching items from the Arrs."); - } - return false; - } - - private async Task FetchItemsFromSonarrAsync() - { - try - { - var items = await sonarrClient.FetchAllItemsAsync(); - UpdateSearchItems(items); - return items?.Any()?? false; - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while updating search item from Sonarr."); - } - return false; - } - - private async Task FetchItemsFromLidarrAsync() - { - try - { - var items = await lidarrClient.FetchAllItemsAsync(); - UpdateSearchItems(items); - return items?.Any() ?? false; - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while updating search item from Lidarr."); - } - return false; - } - - private async Task FetchItemsFromReadarrAsync() - { - try - { - var items = await readarrClient.FetchAllItemsAsync(); - UpdateSearchItems(items); - return items?.Any() ?? false; - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while updating search item from Lidarr."); - } - return false; - } - - private void UpdateSearchItems(IEnumerable? searchItems) - { - foreach (var searchItem in searchItems ?? []) - { - try - { - cacheService.CacheSearchItem(searchItem); - } - catch (Exception ex) - { - logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); + logger.LogInformation( + "ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row."); + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } } + + logger.LogInformation("ArrSyncBackgroundService is stopping."); } -} + + private async Task FetchAndUpdateDataAsync() + { + try + { + var success = true; + + + if (RrApplicationFactory.SonarrInstances.Any()) + { + var syncSuccess = await FetchItemsFromSonarrAsync(); + success = success && syncSuccess; + } + + if (RrApplicationFactory.ReadarrInstances.Any()) + { + var syncSuccess = await FetchItemsFromReadarrAsync(); + success = success && syncSuccess; + } + + if (RrApplicationFactory.ReadarrInstances.Any()) + { + var syncSuccess = await FetchItemsFromLidarrAsync(); + success = success && syncSuccess; + } + + + return success; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while fetching items from the Arrs."); + } + + return false; + } + + private async Task FetchItemsFromSonarrAsync() + { + try + { + var items = new List(); + + foreach (var sonarrClient in RrApplicationFactory.SonarrInstances) + { + var result = await sonarrClient.FetchAllItemsAsync(); + items = items.Union(result).ToList(); + } + + + UpdateSearchItems(items); + return items?.Any() ?? false; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while updating search item from Sonarr."); + } + + return false; + } + + private async Task FetchItemsFromLidarrAsync() + { + try + { + var items = new List(); + + foreach (var lidarrClient in RrApplicationFactory.LidarrInstances) + { + var result = await lidarrClient.FetchAllItemsAsync(); + items = items.Union(result).ToList(); + } + + UpdateSearchItems(items); + return items?.Any() ?? false; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while updating search item from Lidarr."); + } + + return false; + } + + private async Task FetchItemsFromReadarrAsync() + { + try + { + var items = new List(); + + foreach (var readarrClient in RrApplicationFactory.ReadarrInstances) + { + var result = await readarrClient.FetchAllItemsAsync(); + items = items.Union(result).ToList(); + } + + UpdateSearchItems(items); + return items?.Any() ?? false; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while updating search item from Lidarr."); + } + + return false; + } + + private void UpdateSearchItems(IEnumerable? searchItems) + { + foreach (var searchItem in searchItems ?? []) + try + { + cacheService.CacheSearchItem(searchItem); + } + catch (Exception ex) + { + logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); + } + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs b/UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs new file mode 100644 index 0000000..f9dd4e5 --- /dev/null +++ b/UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs @@ -0,0 +1,76 @@ +using UmlautAdaptarr.Interfaces; +using UmlautAdaptarr.Providers; + +namespace UmlautAdaptarr.Services.Factory +{ + /// + /// Factory for creating RrApplication instances. + /// + public class RrApplicationFactory + { + private readonly ILogger _logger; + + /// + /// Get all IArrApplication instances. + /// + public IDictionary AllInstances { get; init; } + + /// + /// Get all SonarrClient instances. + /// + public IEnumerable SonarrInstances { get; init; } + + /// + /// Get all LidarrClient instances. + /// + public IEnumerable LidarrInstances { get; init; } + + /// + /// Get all ReadarrClient instances. + /// + public IEnumerable ReadarrInstances { get; init; } + + /// + /// Constructor for the RrApplicationFactory. + /// + /// A dictionary of IArrApplication instances. + public RrApplicationFactory(IDictionary rrArrApplications, ILogger logger) + { + _logger = logger; + try + { + SonarrInstances = rrArrApplications.Values.OfType(); + LidarrInstances = rrArrApplications.Values.OfType(); + ReadarrInstances = rrArrApplications.Values.OfType(); + AllInstances = rrArrApplications; + + if (!AllInstances.Values.Any()) + { + throw new Exception("No RrApplication could be successfully initialized. This could be due to a faulty configuration"); + } + } + catch (Exception e) + { + _logger.LogError("Register RrFactory", e.Message); + throw; + } + } + + /// + /// Returns an IArrApplication instance that matches the given name. + /// + /// The name of the IArrApplication instance being sought. + /// The IArrApplication instance that matches the given name. + /// Thrown when no IArrApplication instance with the given name can be found. + public IArrApplication GetArrInstanceByName(string nameOfArrInstance) + { + var instance = AllInstances.FirstOrDefault(up => up.Key.Equals(nameOfArrInstance)).Value; + if (instance == null) + { + throw new ArgumentException($"No ArrService with the name {nameOfArrInstance} could be found"); + } + + return instance; + } + } +} diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index eb68495..0842db0 100644 --- a/UmlautAdaptarr/Services/SearchItemLookupService.cs +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -1,12 +1,11 @@ using UmlautAdaptarr.Models; using UmlautAdaptarr.Providers; +using UmlautAdaptarr.Services.Factory; namespace UmlautAdaptarr.Services { public class SearchItemLookupService(CacheService cacheService, - SonarrClient sonarrClient, - ReadarrClient readarrClient, - LidarrClient lidarrClient) + RrApplicationFactory rrApplicationFactory) { public async Task GetOrFetchSearchItemByExternalId(string mediaType, string externalId) { @@ -22,23 +21,40 @@ namespace UmlautAdaptarr.Services switch (mediaType) { case "tv": - if (sonarrClient.SonarrOptions.Enabled) + + var sonarrInstances = rrApplicationFactory.SonarrInstances; + + if (sonarrInstances.Any()) { - fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); + foreach (var sonarrClient in sonarrInstances) + { + fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); + } } break; case "audio": - if (lidarrClient.LidarrOptions.Enabled) + + var lidarrInstances = rrApplicationFactory.LidarrInstances; + + if (lidarrInstances.Any()) { - await lidarrClient.FetchItemByExternalIdAsync(externalId); - fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); + foreach (var lidarrClient in lidarrInstances) + { + await lidarrClient.FetchItemByExternalIdAsync(externalId); + fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); + } } break; case "book": - if (readarrClient.ReadarrOptions.Enabled) + + var readarrInstances = rrApplicationFactory.ReadarrInstances; + if (readarrInstances.Any()) { - await readarrClient.FetchItemByExternalIdAsync(externalId); - fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); + foreach (var readarrClient in readarrInstances) + { + await readarrClient.FetchItemByExternalIdAsync(externalId); + fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); + } } break; } @@ -66,7 +82,9 @@ namespace UmlautAdaptarr.Services switch (mediaType) { case "tv": - if (sonarrClient.SonarrOptions.Enabled) + + var sonarrInstances = rrApplicationFactory.SonarrInstances; + foreach (var sonarrClient in sonarrInstances) { fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); } diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 23722e0..45fb58c 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -12,6 +12,7 @@ + diff --git a/UmlautAdaptarr/Utilities/ApiKeyMaskingEnricher.cs b/UmlautAdaptarr/Utilities/ApiKeyMaskingEnricher.cs new file mode 100644 index 0000000..fff709d --- /dev/null +++ b/UmlautAdaptarr/Utilities/ApiKeyMaskingEnricher.cs @@ -0,0 +1,69 @@ +using System.Collections; +using System.Text.RegularExpressions; +using Serilog.Core; +using Serilog.Events; + +namespace UmlautAdaptarr.Utilities; + +public class ApiKeyMaskingEnricher : ILogEventEnricher +{ + private readonly List apiKeys = new(); + + public ApiKeyMaskingEnricher(string appsetting) + { + ExtractApiKeysFromAppSettings(appsetting); + ExtractApiKeysFromEnvironmentVariables(); + apiKeys = new List(apiKeys.Distinct()); + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + //if (logEvent.Properties.TryGetValue("apikey", out var value) && value is ScalarValue scalarValue) + //{ + var maskedValue = new ScalarValue("**Hidden Api Key**"); + foreach (var apikey in apiKeys) logEvent.AddOrUpdateProperty(new LogEventProperty(apikey, maskedValue)); + // } + } + + + /// + /// Scan all Env Variabels for known Apikeys + /// + /// List of all Apikeys + public List ExtractApiKeysFromEnvironmentVariables() + { + var envVariables = Environment.GetEnvironmentVariables(); + + foreach (DictionaryEntry envVariable in envVariables) + if (envVariable.Key.ToString()!.Contains("ApiKey")) + apiKeys.Add(envVariable.Value.ToString()); + + return apiKeys; + } + + + public List ExtractApiKeysFromAppSettings(string filePath) + { + try + { + if (File.Exists(filePath)) + { + var fileContent = File.ReadAllText(filePath); + + var pattern = "\"ApiKey\": \"(.*?)\""; + var regex = new Regex(pattern); + var matches = regex.Matches(fileContent); + + foreach (Match match in matches) apiKeys.Add(match.Groups[1].Value); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + + return apiKeys; + } +} + + diff --git a/UmlautAdaptarr/Utilities/Helper.cs b/UmlautAdaptarr/Utilities/Helper.cs new file mode 100644 index 0000000..9a5bd75 --- /dev/null +++ b/UmlautAdaptarr/Utilities/Helper.cs @@ -0,0 +1,10 @@ +namespace UmlautAdaptarr.Utilities +{ + public static class Helper + { + public static void ShowLogo() + { + Console.WriteLine("\r\n _ _ _ _ ___ _ _ \r\n| | | | | | | | / _ \\ | | | | \r\n| | | |_ __ ___ | | __ _ _ _| |_/ /_\\ \\ __| | __ _ _ __ | |_ __ _ _ __ _ __ \r\n| | | | '_ ` _ \\| |/ _` | | | | __| _ |/ _` |/ _` | '_ \\| __/ _` | '__| '__|\r\n| |_| | | | | | | | (_| | |_| | |_| | | | (_| | (_| | |_) | || (_| | | | | \r\n \\___/|_| |_| |_|_|\\__,_|\\__,_|\\__\\_| |_/\\__,_|\\__,_| .__/ \\__\\__,_|_| |_| \r\n | | \r\n |_| \r\n"); + } + } +} diff --git a/UmlautAdaptarr/Utilities/KeyedServiceExtensions.cs b/UmlautAdaptarr/Utilities/KeyedServiceExtensions.cs new file mode 100644 index 0000000..55809ab --- /dev/null +++ b/UmlautAdaptarr/Utilities/KeyedServiceExtensions.cs @@ -0,0 +1,74 @@ +using System.Collections; +using System.Collections.ObjectModel; + +namespace UmlautAdaptarr.Utilities +{ + // License: This code is published under the MIT license. + // Source: https://stackoverflow.com/questions/77559201/ + public static class KeyedServiceExtensions + { + public static void AllowResolvingKeyedServicesAsDictionary( + this IServiceCollection sc) + { + // KeyedServiceCache caches all the keys of a given type for a + // specific service type. By making it a singleton we only have + // determine the keys once, which makes resolving the dict very fast. + sc.AddSingleton(typeof(KeyedServiceCache<,>)); + + // KeyedServiceCache depends on the IServiceCollection to get + // the list of keys. That's why we register that here as well, as it + // is not registered by default in MS.DI. + sc.AddSingleton(sc); + + // Last we make the registration for the dictionary itself, which maps + // to our custom type below. This registration must be transient, as + // the containing services could have any lifetime and this registration + // should by itself not cause Captive Dependencies. + sc.AddTransient(typeof(IDictionary<,>), typeof(KeyedServiceDictionary<,>)); + + // For completeness, let's also allow IReadOnlyDictionary to be resolved. + sc.AddTransient( + typeof(IReadOnlyDictionary<,>), typeof(KeyedServiceDictionary<,>)); + } + + // We inherit from ReadOnlyDictionary, to disallow consumers from changing + // the wrapped dependencies while reusing all its functionality. This way + // we don't have to implement IDictionary ourselves; too much work. + private sealed class KeyedServiceDictionary( + KeyedServiceCache keys, IServiceProvider provider) + : ReadOnlyDictionary(Create(keys, provider)) + where TKey : notnull + where TService : notnull + { + private static Dictionary Create( + KeyedServiceCache keys, IServiceProvider provider) + { + var dict = new Dictionary(capacity: keys.Keys.Length); + + foreach (TKey key in keys.Keys) + { + dict[key] = provider.GetRequiredKeyedService(key); + } + + return dict; + } + } + + private sealed class KeyedServiceCache(IServiceCollection sc) + where TKey : notnull + where TService : notnull + { + // Once this class is resolved, all registrations are guaranteed to be + // made, so we can, at that point, safely iterate the collection to get + // the keys for the service type. + public TKey[] Keys { get; } = ( + from service in sc + where service.ServiceKey != null + where service.ServiceKey!.GetType() == typeof(TKey) + where service.ServiceType == typeof(TService) + select (TKey)service.ServiceKey!) + .ToArray(); + } + + } +} diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 2cc188d..f70b6a1 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -1,92 +1,145 @@ -using UmlautAdaptarr.Options; -using UmlautAdaptarr.Options.ArrOptions; +using UmlautAdaptarr.Interfaces; +using UmlautAdaptarr.Options; +using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Services; -namespace UmlautAdaptarr.Utilities +namespace UmlautAdaptarr.Utilities; + +/// +/// Extension methods for configuring services related to ARR Applications +/// +public static class ServicesExtensions { /// - /// Extension methods for configuring services related to ARR Applications + /// Adds a service with specified options and service to the service collection. /// - public static class ServicesExtensions + /// The options type for the service. + /// The service type for the service. + /// The Interface of the service type + /// The to configure the service collection. + /// The name of the configuration section containing service options. + /// The configured . + private static WebApplicationBuilder AddServicesWithOptions( + this WebApplicationBuilder builder, string sectionName) + where TOptions : class, new() + where TService : class, TInterface + where TInterface : class { + if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null."); - /// - /// Adds a service with specified options and service to the service collection. - /// - /// The options type for the service. - /// The service type for the service. - /// The to configure the service collection. - /// The name of the configuration section containing service options. - /// The configured . - private static WebApplicationBuilder AddServiceWithOptions(this WebApplicationBuilder builder, string sectionName) - where TOptions : class - where TService : class + + var singleInstance = builder.Configuration.GetSection(sectionName).Get(); + + var singleHost = (string?)typeof(TOptions).GetProperty("Host")?.GetValue(singleInstance, null); + + // If we have no Single Instance , we try to parse for a Array + var optionsArray = singleHost == null + ? builder.Configuration.GetSection(sectionName).Get() + : + [ + singleInstance + ]; + + if (optionsArray == null || !optionsArray.Any()) + throw new InvalidOperationException( + $"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable."); + + foreach (var options in optionsArray) { - if (builder.Services == null) + var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(options, null) ?? false); + + // We only want to create instances that are enabled in the Configs + if (instanceState) { - throw new ArgumentNullException(nameof(builder), "Service collection is null."); + // User can give the Instance a readable Name otherwise we use the Host Property + var instanceName = (string)(typeof(TOptions).GetProperty("Name")?.GetValue(options, null) ?? + (string)typeof(TOptions).GetProperty("Host")?.GetValue(options, null)!); + instanceName = instanceName.Replace(".", ""); + builder.Services.Configure(instanceName, + delegate(TOptions serviceOptions) { serviceOptions = options; }); + + builder.Services.AllowResolvingKeyedServicesAsDictionary(); + builder.Services.AddKeyedSingleton(instanceName); } - - var options = builder.Configuration.GetSection(sectionName).Get(); - if (options == null) - { - throw new InvalidOperationException($"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable."); - } - - builder.Services.Configure(builder.Configuration.GetSection(sectionName)); - builder.Services.AddSingleton(); - return builder; } - /// - /// Adds support for Sonarr with default options and client. - /// - /// The to configure the service collection. - /// The configured . - public static WebApplicationBuilder AddSonarrSupport(this WebApplicationBuilder builder) - { - return builder.AddServiceWithOptions("Sonarr"); - } - - /// - /// Adds support for Lidarr with default options and client. - /// - /// The to configure the service collection. - /// The configured . - public static WebApplicationBuilder AddLidarrSupport(this WebApplicationBuilder builder) - { - return builder.AddServiceWithOptions("Lidarr"); - } - - /// - /// Adds support for Readarr with default options and client. - /// - /// The to configure the service collection. - /// The configured . - public static WebApplicationBuilder AddReadarrSupport(this WebApplicationBuilder builder) - { - return builder.AddServiceWithOptions("Readarr"); - } - - /// - /// Adds a title lookup service to the service collection. - /// - /// The to configure the service collection. - /// The configured . - public static WebApplicationBuilder AddTitleLookupService(this WebApplicationBuilder builder) - { - return builder.AddServiceWithOptions("Settings"); - } - - /// - /// Adds a proxy request service to the service collection. - /// - /// The to configure the service collection. - /// The configured . - public static WebApplicationBuilder AddProxyRequestService(this WebApplicationBuilder builder) - { - return builder.AddServiceWithOptions("Settings"); - } + return builder; } -} + + /// + /// Adds a service with specified options and service to the service collection. + /// + /// The options type for the service. + /// The service type for the service. + /// The to configure the service collection. + /// The name of the configuration section containing service options. + /// The configured . + private static WebApplicationBuilder AddServiceWithOptions(this WebApplicationBuilder builder, + string sectionName) + where TOptions : class + where TService : class + { + if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null."); + + var options = builder.Configuration.GetSection(sectionName).Get(); + if (options == null) + throw new InvalidOperationException( + $"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable."); + + builder.Services.Configure(builder.Configuration.GetSection(sectionName)); + builder.Services.AddSingleton(); + + return builder; + } + + /// + /// Adds support for Sonarr with default options and client. + /// + /// The to configure the service collection. + /// The configured . + public static WebApplicationBuilder AddSonarrSupport(this WebApplicationBuilder builder) + { + return builder.AddServicesWithOptions("Sonarr"); + } + + /// + /// Adds support for Lidarr with default options and client. + /// + /// The to configure the service collection. + /// The configured . + public static WebApplicationBuilder AddLidarrSupport(this WebApplicationBuilder builder) + { + return builder.AddServicesWithOptions("Lidarr"); + } + + /// + /// Adds support for Readarr with default options and client. + /// + /// The to configure the service collection. + /// The configured . + public static WebApplicationBuilder AddReadarrSupport(this WebApplicationBuilder builder) + { + return builder.AddServicesWithOptions("Readarr"); + } + + /// + /// Adds a title lookup service to the service collection. + /// + /// The to configure the service collection. + /// The configured . + public static WebApplicationBuilder AddTitleLookupService(this WebApplicationBuilder builder) + { + return builder.AddServiceWithOptions("Settings"); + } + + /// + /// Adds a proxy request service to the service collection. + /// + /// The to configure the service collection. + /// The configured . + public static WebApplicationBuilder AddProxyRequestService(this WebApplicationBuilder builder) + { + return builder.AddServiceWithOptions("Settings"); + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 56b0dde..4118dc8 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -22,20 +22,32 @@ "UserAgent": "UmlautAdaptarr/1.0", "UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1" }, - "Sonarr": { - // Docker Environment Variables: - // - Sonarr__Enabled: true (set to false to disable) - // - Sonarr__Host: your_sonarr_host_url - // - Sonarr__ApiKey: your_sonarr_api_key - "Enabled": false, - "Host": "your_sonarr_host_url", - "ApiKey": "your_sonarr_api_key" - }, - "Lidarr": { - // Docker Environment Variables: - // - Lidarr__Enabled: true (set to false to disable) - // - Lidarr__Host: your_lidarr_host_url - // - Lidarr__ApiKey: your_lidarr_api_key + "Sonarr": [ + { + // Docker Environment Variables: + // - Sonarr__0__Enabled: true (set to false to disable) + // - Sonarr__0__Host: your_sonarr_host_url + // - Sonarr__0__ApiKey: your_sonarr_api_key + "Enabled": false, + "Host": "your_sonarr_host_url", + "ApiKey": "your_sonarr_api_key" + }, + { + // Docker Environment Variables: + // - Sonarr__1__Enabled: true (set to false to disable) + // - Sonarr__1__Host: your_sonarr_host_url + // - Sonarr__1__ApiKey: your_sonarr_api_key + "Enabled": false, + "Host": "your_other_sonarr_host_url", + "ApiKey": "your_other_sonarr_api_key" + } + ], + "Lidarr": + // Docker Environment Variables: + // - Lidarr__Enabled: true (set to false to disable) + // - Lidarr__Host: your_lidarr_host_url + // - Lidarr__ApiKey: your_lidarr_api_key + { "Enabled": false, "Host": "your_lidarr_host_url", "ApiKey": "your_lidarr_api_key"