From f06a866a2f1403ca3bc06523ab202b2d4aea5b2e Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 18:48:43 +0200 Subject: [PATCH 01/12] 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" From 52acb5ff6e6266475d4d81bd6a81c91a485935a6 Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 21:27:04 +0200 Subject: [PATCH 02/12] Fix named IOption Bug Fix named IOption Bug --- .../InstanceOptions/GlobalInstanceOptions.cs | 5 ++++ .../Utilities/ServicesExtensions.cs | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs index e41c492..2d3e989 100644 --- a/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs @@ -7,6 +7,11 @@ /// public bool Enabled { get; set; } + /// + /// Name of the Instance + /// + public string Name { get; set; } + /// /// The host of the ARR application. /// diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index f70b6a1..a12a1b2 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -1,4 +1,5 @@ -using UmlautAdaptarr.Interfaces; +using System.Linq.Expressions; +using UmlautAdaptarr.Interfaces; using UmlautAdaptarr.Options; using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Providers; @@ -45,19 +46,30 @@ public static class ServicesExtensions throw new InvalidOperationException( $"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable."); - foreach (var options in optionsArray) + foreach (var option in optionsArray) { - var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(options, null) ?? false); + var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false); // We only want to create instances that are enabled in the Configs if (instanceState) { // 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; }); + var instanceName = (string)(typeof(TOptions).GetProperty("Name")?.GetValue(option, null) ?? + (string)typeof(TOptions).GetProperty("Host")?.GetValue(option, null)!); + + // Dark Magic , we don't know the Property's of TOptions , and we won't cast them for each Options + var paraexpression = Expression.Parameter(option.GetType(), "x"); + + foreach (var prop in option.GetType().GetProperties()) + { + var val = Expression.Constant(prop.GetValue(option)); + var memberexpression = Expression.PropertyOrField(paraexpression, prop.Name); + + var assign = Expression.Assign(memberexpression, val); + + var exp = Expression.Lambda>(assign, paraexpression); + builder.Services.Configure(instanceName, exp.Compile()); + } builder.Services.AllowResolvingKeyedServicesAsDictionary(); builder.Services.AddKeyedSingleton(instanceName); @@ -100,6 +112,7 @@ public static class ServicesExtensions /// The configured . public static WebApplicationBuilder AddSonarrSupport(this WebApplicationBuilder builder) { + // builder.Serviceses.AddSingleton, OptionsMonitoSonarrInstanceOptionsns>>(); return builder.AddServicesWithOptions("Sonarr"); } From e6173ae6836fc4ad7dbc8a02cedcf284671189f0 Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 21:29:23 +0200 Subject: [PATCH 03/12] Add Example for Name --- UmlautAdaptarr/appsettings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 4118dc8..6552fc6 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -29,6 +29,7 @@ // - Sonarr__0__Host: your_sonarr_host_url // - Sonarr__0__ApiKey: your_sonarr_api_key "Enabled": false, + "Name": "Sonarr", "Host": "your_sonarr_host_url", "ApiKey": "your_sonarr_api_key" }, @@ -38,6 +39,7 @@ // - Sonarr__1__Host: your_sonarr_host_url // - Sonarr__1__ApiKey: your_sonarr_api_key "Enabled": false, + "Name": "Sonarr 4k", "Host": "your_other_sonarr_host_url", "ApiKey": "your_other_sonarr_api_key" } From f73b3b5578913ce503fb24819bde804bfae7ae36 Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 21:53:12 +0200 Subject: [PATCH 04/12] Fix in IOptions Copy Section --- UmlautAdaptarr/Utilities/ServicesExtensions.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index a12a1b2..06e1b36 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -58,19 +58,27 @@ public static class ServicesExtensions (string)typeof(TOptions).GetProperty("Host")?.GetValue(option, null)!); // Dark Magic , we don't know the Property's of TOptions , and we won't cast them for each Options - var paraexpression = Expression.Parameter(option.GetType(), "x"); + // Todo eventuell schönere Lösung finden + var paraexpression = Expression.Parameter(Type.GetType(option.GetType().FullName), "x"); foreach (var prop in option.GetType().GetProperties()) { var val = Expression.Constant(prop.GetValue(option)); var memberexpression = Expression.PropertyOrField(paraexpression, prop.Name); - var assign = Expression.Assign(memberexpression, val); - - var exp = Expression.Lambda>(assign, paraexpression); - builder.Services.Configure(instanceName, exp.Compile()); + if (prop.PropertyType == typeof(int) || prop.PropertyType == typeof(string) || prop.PropertyType == typeof(bool)) + { + var assign = Expression.Assign(memberexpression, Expression.Convert(val, prop.PropertyType)); + var exp = Expression.Lambda>(assign, paraexpression); + builder.Services.Configure(instanceName, exp.Compile()); + } + else + { + Console.WriteLine(prop.PropertyType + "No Support"); + } } + builder.Services.AllowResolvingKeyedServicesAsDictionary(); builder.Services.AddKeyedSingleton(instanceName); } From 0bb480b1d0bbedc4d8b02ec4a6729b04c8eaf88e Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sun, 28 Apr 2024 12:59:44 +0200 Subject: [PATCH 05/12] Add Config Validator + Bug Fixing --- README.md | 1 + UmlautAdaptarr/Program.cs | 4 +- .../Services/ArrSyncBackgroundService.cs | 16 +-- ...ionFactory.cs => ArrApplicationFactory.cs} | 11 +- .../Services/SearchItemLookupService.cs | 10 +- UmlautAdaptarr/UmlautAdaptarr.csproj | 2 + .../Utilities/ServicesExtensions.cs | 121 +++++++++++------- .../GlobalInstanceOptionsValidator.cs | 46 +++++++ UmlautAdaptarr/appsettings.json | 2 + 9 files changed, 148 insertions(+), 65 deletions(-) rename UmlautAdaptarr/Services/Factory/{RrApplicationFactory.cs => ArrApplicationFactory.cs} (84%) create mode 100644 UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs diff --git a/README.md b/README.md index f02cd50..be4edae 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda | Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Usenet (newznab) Support |✓| | Torrent (torznab) Support |✓| +| Support von meheren *arrs Instanzen | ✓ | Radarr Support | Geplant | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant | diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index eb4479f..1d6e666 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -53,7 +53,7 @@ internal class Program //options.SizeLimit = 20000; }); - + builder.Services.AllowResolvingKeyedServicesAsDictionary(); builder.Services.AddControllers(); builder.AddTitleLookupService(); builder.Services.AddSingleton(); @@ -63,7 +63,7 @@ internal class Program builder.AddReadarrSupport(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs index 181e091..61d4894 100644 --- a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -4,12 +4,12 @@ using UmlautAdaptarr.Services.Factory; namespace UmlautAdaptarr.Services; public class ArrSyncBackgroundService( - RrApplicationFactory rrApplicationFactory, + ArrApplicationFactory arrApplicationFactory, CacheService cacheService, ILogger logger) : BackgroundService { - public RrApplicationFactory RrApplicationFactory { get; } = rrApplicationFactory; + public ArrApplicationFactory ArrApplicationFactory { get; } = arrApplicationFactory; protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -56,19 +56,19 @@ public class ArrSyncBackgroundService( var success = true; - if (RrApplicationFactory.SonarrInstances.Any()) + if (ArrApplicationFactory.SonarrInstances.Any()) { var syncSuccess = await FetchItemsFromSonarrAsync(); success = success && syncSuccess; } - if (RrApplicationFactory.ReadarrInstances.Any()) + if (ArrApplicationFactory.ReadarrInstances.Any()) { var syncSuccess = await FetchItemsFromReadarrAsync(); success = success && syncSuccess; } - if (RrApplicationFactory.ReadarrInstances.Any()) + if (ArrApplicationFactory.ReadarrInstances.Any()) { var syncSuccess = await FetchItemsFromLidarrAsync(); success = success && syncSuccess; @@ -91,7 +91,7 @@ public class ArrSyncBackgroundService( { var items = new List(); - foreach (var sonarrClient in RrApplicationFactory.SonarrInstances) + foreach (var sonarrClient in ArrApplicationFactory.SonarrInstances) { var result = await sonarrClient.FetchAllItemsAsync(); items = items.Union(result).ToList(); @@ -115,7 +115,7 @@ public class ArrSyncBackgroundService( { var items = new List(); - foreach (var lidarrClient in RrApplicationFactory.LidarrInstances) + foreach (var lidarrClient in ArrApplicationFactory.LidarrInstances) { var result = await lidarrClient.FetchAllItemsAsync(); items = items.Union(result).ToList(); @@ -138,7 +138,7 @@ public class ArrSyncBackgroundService( { var items = new List(); - foreach (var readarrClient in RrApplicationFactory.ReadarrInstances) + foreach (var readarrClient in ArrApplicationFactory.ReadarrInstances) { var result = await readarrClient.FetchAllItemsAsync(); items = items.Union(result).ToList(); diff --git a/UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs b/UmlautAdaptarr/Services/Factory/ArrApplicationFactory.cs similarity index 84% rename from UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs rename to UmlautAdaptarr/Services/Factory/ArrApplicationFactory.cs index f9dd4e5..2fe4ddc 100644 --- a/UmlautAdaptarr/Services/Factory/RrApplicationFactory.cs +++ b/UmlautAdaptarr/Services/Factory/ArrApplicationFactory.cs @@ -6,9 +6,9 @@ namespace UmlautAdaptarr.Services.Factory /// /// Factory for creating RrApplication instances. /// - public class RrApplicationFactory + public class ArrApplicationFactory { - private readonly ILogger _logger; + private readonly ILogger _logger; /// /// Get all IArrApplication instances. @@ -31,10 +31,11 @@ namespace UmlautAdaptarr.Services.Factory public IEnumerable ReadarrInstances { get; init; } /// - /// Constructor for the RrApplicationFactory. + /// Constructor for the ArrApplicationFactory. /// /// A dictionary of IArrApplication instances. - public RrApplicationFactory(IDictionary rrArrApplications, ILogger logger) + /// Logger Instanz + public ArrApplicationFactory(IDictionary rrArrApplications, ILogger logger) { _logger = logger; try @@ -51,7 +52,7 @@ namespace UmlautAdaptarr.Services.Factory } catch (Exception e) { - _logger.LogError("Register RrFactory", e.Message); + _logger.LogError("Error while Register ArrFactory. This might be a Config Problem", e.Message); throw; } } diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index 0842db0..606133a 100644 --- a/UmlautAdaptarr/Services/SearchItemLookupService.cs +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -5,7 +5,7 @@ using UmlautAdaptarr.Services.Factory; namespace UmlautAdaptarr.Services { public class SearchItemLookupService(CacheService cacheService, - RrApplicationFactory rrApplicationFactory) + ArrApplicationFactory arrApplicationFactory) { public async Task GetOrFetchSearchItemByExternalId(string mediaType, string externalId) { @@ -22,7 +22,7 @@ namespace UmlautAdaptarr.Services { case "tv": - var sonarrInstances = rrApplicationFactory.SonarrInstances; + var sonarrInstances = arrApplicationFactory.SonarrInstances; if (sonarrInstances.Any()) { @@ -34,7 +34,7 @@ namespace UmlautAdaptarr.Services break; case "audio": - var lidarrInstances = rrApplicationFactory.LidarrInstances; + var lidarrInstances = arrApplicationFactory.LidarrInstances; if (lidarrInstances.Any()) { @@ -47,7 +47,7 @@ namespace UmlautAdaptarr.Services break; case "book": - var readarrInstances = rrApplicationFactory.ReadarrInstances; + var readarrInstances = arrApplicationFactory.ReadarrInstances; if (readarrInstances.Any()) { foreach (var readarrClient in readarrInstances) @@ -83,7 +83,7 @@ namespace UmlautAdaptarr.Services { case "tv": - var sonarrInstances = rrApplicationFactory.SonarrInstances; + var sonarrInstances = arrApplicationFactory.SonarrInstances; foreach (var sonarrClient in sonarrInstances) { fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 45fb58c..b03a7e2 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,6 +9,8 @@ + + diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 06e1b36..3e46e4e 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -1,9 +1,11 @@ -using System.Linq.Expressions; +using FluentValidation; +using System.Linq.Expressions; using UmlautAdaptarr.Interfaces; using UmlautAdaptarr.Options; using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Services; +using UmlautAdaptarr.Validator; namespace UmlautAdaptarr.Utilities; @@ -12,6 +14,12 @@ namespace UmlautAdaptarr.Utilities; /// public static class ServicesExtensions { + + /// + /// Logger instance for logging proxy configurations. + /// + private static ILogger Logger = GlobalStaticLogger.Logger; + /// /// Adds a service with specified options and service to the service collection. /// @@ -27,64 +35,87 @@ public static class ServicesExtensions where TService : class, TInterface where TInterface : class { - if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null."); - - 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 option in optionsArray) + try { - var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false); + if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null."); - // We only want to create instances that are enabled in the Configs - if (instanceState) + + 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 option in optionsArray) { - // User can give the Instance a readable Name otherwise we use the Host Property - var instanceName = (string)(typeof(TOptions).GetProperty("Name")?.GetValue(option, null) ?? - (string)typeof(TOptions).GetProperty("Host")?.GetValue(option, null)!); - // Dark Magic , we don't know the Property's of TOptions , and we won't cast them for each Options - // Todo eventuell schönere Lösung finden - var paraexpression = Expression.Parameter(Type.GetType(option.GetType().FullName), "x"); + GlobalInstanceOptionsValidator validator = new GlobalInstanceOptionsValidator(); - foreach (var prop in option.GetType().GetProperties()) + var results = validator.Validate(option as GlobalInstanceOptions); + + if (!results.IsValid) { - var val = Expression.Constant(prop.GetValue(option)); - var memberexpression = Expression.PropertyOrField(paraexpression, prop.Name); + foreach (var failure in results.Errors) + { + Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}")); + } - if (prop.PropertyType == typeof(int) || prop.PropertyType == typeof(string) || prop.PropertyType == typeof(bool)) - { - var assign = Expression.Assign(memberexpression, Expression.Convert(val, prop.PropertyType)); - var exp = Expression.Lambda>(assign, paraexpression); - builder.Services.Configure(instanceName, exp.Compile()); - } - else - { - Console.WriteLine(prop.PropertyType + "No Support"); - } + throw new Exception("Please fix first you config and then Start UmlautAdaptarr again"); } + var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false); - builder.Services.AllowResolvingKeyedServicesAsDictionary(); - builder.Services.AddKeyedSingleton(instanceName); + // We only want to create instances that are enabled in the Configs + if (instanceState) + { + // User can give the Instance a readable Name otherwise we use the Host Property + var instanceName = (string)(typeof(TOptions).GetProperty("Name")?.GetValue(option, null) ?? + (string)typeof(TOptions).GetProperty("Host")?.GetValue(option, null)!); + + // Dark Magic , we don't know the Property's of TOptions , and we won't cast them for each Options + // Todo eventuell schönere Lösung finden + var paraexpression = Expression.Parameter(Type.GetType(option.GetType().FullName), "x"); + + foreach (var prop in option.GetType().GetProperties()) + { + var val = Expression.Constant(prop.GetValue(option)); + var memberexpression = Expression.PropertyOrField(paraexpression, prop.Name); + + if (prop.PropertyType == typeof(int) || prop.PropertyType == typeof(string) || prop.PropertyType == typeof(bool)) + { + var assign = Expression.Assign(memberexpression, Expression.Convert(val, prop.PropertyType)); + var exp = Expression.Lambda>(assign, paraexpression); + builder.Services.Configure(instanceName, exp.Compile()); + } + else + { + Logger.LogWarning((prop.PropertyType + "No Support")); + } + } + + builder.Services.AddKeyedSingleton(instanceName); + } } + + return builder; + } + catch (Exception e) + { + Console.WriteLine("Error while Init UmlautAdaptrr"); + throw; } - return builder; } /// diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs new file mode 100644 index 0000000..73b49e0 --- /dev/null +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using System.Net; +using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; + +namespace UmlautAdaptarr.Validator +{ + public class GlobalInstanceOptionsValidator : AbstractValidator + { + public GlobalInstanceOptionsValidator() + { + RuleFor(x => x.Enabled).NotNull(); + + When(x => x.Enabled, () => + { + + RuleFor(x => x.Host) + .NotEmpty().WithMessage("Host is required when Enabled is true.") + .Must(BeAValidUrl).WithMessage("Host must start with http:// or https:// and be a valid address.") + .Must(BeReachable).WithMessage("Host is not reachable. Please check your Host or your UmlautAdaptrr Settings"); + + RuleFor(x => x.ApiKey) + .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); + }); + } + + private bool BeAValidUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + } + + private bool BeReachable(string url) + { + try + { + var request = WebRequest.Create(url); + var response = (HttpWebResponse)request.GetResponse(); + return response.StatusCode == HttpStatusCode.OK; + } + catch + { + return false; + } + } + } +} diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 6552fc6..70e3e40 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -26,6 +26,7 @@ { // Docker Environment Variables: // - Sonarr__0__Enabled: true (set to false to disable) + // - Sonarr__0__Name: Name of the Instance (Optional) // - Sonarr__0__Host: your_sonarr_host_url // - Sonarr__0__ApiKey: your_sonarr_api_key "Enabled": false, @@ -36,6 +37,7 @@ { // Docker Environment Variables: // - Sonarr__1__Enabled: true (set to false to disable) + // - Sonarr__0__Name: Name of the Instance (Optional) // - Sonarr__1__Host: your_sonarr_host_url // - Sonarr__1__ApiKey: your_sonarr_api_key "Enabled": false, From c788e0ed76c1c5b01cf71a53c86bd70a67b0ad51 Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sun, 28 Apr 2024 13:21:48 +0200 Subject: [PATCH 06/12] Fix Log --- UmlautAdaptarr/Providers/ReadarrClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UmlautAdaptarr/Providers/ReadarrClient.cs b/UmlautAdaptarr/Providers/ReadarrClient.cs index e29db88..55a7493 100644 --- a/UmlautAdaptarr/Providers/ReadarrClient.cs +++ b/UmlautAdaptarr/Providers/ReadarrClient.cs @@ -61,7 +61,7 @@ public class ReadarrClient : ArrClientBase // TODO add caching here _logger.LogInformation( - $"Fetching all books from authorId {authorId} from Readarr: {UrlUtilities.RedactApiKey(readarrBookUrl)}"); + $"Fetching all books from authorId {authorId} from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrBookUrl)}"); var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl); var books = JsonConvert.DeserializeObject>(bookApiResponse); From 5931fd6a8adcc1c278f7cfeecdbf73c0d7276e5b Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Mon, 29 Apr 2024 20:21:46 +0200 Subject: [PATCH 07/12] Fix Bug If UmlautAdaparr was started before the *Arr. The BeReachable test failed, although the config was correct. Now it is tested every 15 seconds for 3 minutes whether the corresponding application can be reached. Before the test fails --- .../GlobalInstanceOptionsValidator.cs | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 73b49e0..70263d6 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -15,8 +15,8 @@ namespace UmlautAdaptarr.Validator RuleFor(x => x.Host) .NotEmpty().WithMessage("Host is required when Enabled is true.") - .Must(BeAValidUrl).WithMessage("Host must start with http:// or https:// and be a valid address.") - .Must(BeReachable).WithMessage("Host is not reachable. Please check your Host or your UmlautAdaptrr Settings"); + .Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.") + .Must(BeReachable).WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings"); RuleFor(x => x.ApiKey) .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); @@ -29,18 +29,37 @@ namespace UmlautAdaptarr.Validator && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); } - private bool BeReachable(string url) + private static bool BeReachable(string url) { - try + DateTime endTime = DateTime.Now.AddMinutes(3); + bool reachable = false; + var request = WebRequest.Create(url); + + while (DateTime.Now < endTime) { - var request = WebRequest.Create(url); - var response = (HttpWebResponse)request.GetResponse(); - return response.StatusCode == HttpStatusCode.OK; - } - catch - { - return false; + try + { + + var response = (HttpWebResponse)request.GetResponse(); + reachable = response.StatusCode == HttpStatusCode.OK; + if (reachable) + { + break; + } + else + { + Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds..."); + } + } + catch + { + return false; + } + // Wait for 15 seconds + System.Threading.Thread.Sleep(15000); } + + return reachable; } } } From ef7182888b37cc4011edcc12a7fd95fcd0439e1e Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Mon, 29 Apr 2024 20:35:18 +0200 Subject: [PATCH 08/12] Update GlobalInstanceOptionsValidator.cs Cleanup Code --- .../GlobalInstanceOptionsValidator.cs | 102 +++++++++--------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 70263d6..9ad27e4 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -1,65 +1,59 @@ -using FluentValidation; -using System.Net; +using System.Net; +using FluentValidation; using UmlautAdaptarr.Options.ArrOptions.InstanceOptions; -namespace UmlautAdaptarr.Validator +namespace UmlautAdaptarr.Validator; + +public class GlobalInstanceOptionsValidator : AbstractValidator { - public class GlobalInstanceOptionsValidator : AbstractValidator + public GlobalInstanceOptionsValidator() { - public GlobalInstanceOptionsValidator() - { - RuleFor(x => x.Enabled).NotNull(); + RuleFor(x => x.Enabled).NotNull(); - When(x => x.Enabled, () => + When(x => x.Enabled, () => + { + RuleFor(x => x.Host) + .NotEmpty().WithMessage("Host is required when Enabled is true.") + .Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.") + .Must(BeReachable) + .WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings"); + + RuleFor(x => x.ApiKey) + .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); + }); + } + + private bool BeAValidUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + } + + private static bool BeReachable(string url) + { + var endTime = DateTime.Now.AddMinutes(3); + var reachable = false; + var request = WebRequest.Create(url); + + while (DateTime.Now < endTime) + { + try { - - RuleFor(x => x.Host) - .NotEmpty().WithMessage("Host is required when Enabled is true.") - .Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.") - .Must(BeReachable).WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings"); - - RuleFor(x => x.ApiKey) - .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); - }); - } - - private bool BeAValidUrl(string url) - { - return Uri.TryCreate(url, UriKind.Absolute, out var uriResult) - && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); - } - - private static bool BeReachable(string url) - { - DateTime endTime = DateTime.Now.AddMinutes(3); - bool reachable = false; - var request = WebRequest.Create(url); - - while (DateTime.Now < endTime) + var response = (HttpWebResponse)request.GetResponse(); + reachable = response.StatusCode == HttpStatusCode.OK; + if (reachable) + break; + Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds..."); + } + catch { - try - { - - var response = (HttpWebResponse)request.GetResponse(); - reachable = response.StatusCode == HttpStatusCode.OK; - if (reachable) - { - break; - } - else - { - Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds..."); - } - } - catch - { - return false; - } - // Wait for 15 seconds - System.Threading.Thread.Sleep(15000); + return false; } - return reachable; + // Wait for 15 seconds + Thread.Sleep(15000); } + + return reachable; } -} +} \ No newline at end of file From 265c098630539b4514f4ffa63ce571fe586fbf5d Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sun, 9 Jun 2024 12:27:26 +0200 Subject: [PATCH 09/12] Fix BeReachable --- .../Validator/GlobalInstanceOptionsValidator.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 9ad27e4..60dd042 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -33,27 +33,28 @@ public class GlobalInstanceOptionsValidator : AbstractValidator Date: Sun, 9 Jun 2024 12:34:55 +0200 Subject: [PATCH 10/12] Update GlobalInstanceOptionsValidator.cs Add Max Timeout --- UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 60dd042..f37dc8b 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -39,6 +39,7 @@ public class GlobalInstanceOptionsValidator : AbstractValidator Date: Sun, 9 Jun 2024 13:38:06 +0200 Subject: [PATCH 11/12] Add IP Infos in Startup Now User can simply see , if his VPN is working correctly --- UmlautAdaptarr/Program.cs | 2 +- UmlautAdaptarr/Utilities/Helper.cs | 86 ++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 1d6e666..58c29b6 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -14,7 +14,7 @@ internal class Program Helper.ShowLogo(); - + Helper.ShowInformation(); // TODO: // add option to sort by nzb age var builder = WebApplication.CreateBuilder(args); diff --git a/UmlautAdaptarr/Utilities/Helper.cs b/UmlautAdaptarr/Utilities/Helper.cs index 9a5bd75..9a796bd 100644 --- a/UmlautAdaptarr/Utilities/Helper.cs +++ b/UmlautAdaptarr/Utilities/Helper.cs @@ -1,10 +1,88 @@ -namespace UmlautAdaptarr.Utilities +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace UmlautAdaptarr.Utilities; + +public static class Helper { - public static class Helper + public static void ShowLogo() { - public static void ShowLogo() + Console.WriteLine( + "\r\n _ _ _ _ ___ _ _ \r\n| | | | | | | | / _ \\ | | | | \r\n| | | |_ __ ___ | | __ _ _ _| |_/ /_\\ \\ __| | __ _ _ __ | |_ __ _ _ __ _ __ \r\n| | | | '_ ` _ \\| |/ _` | | | | __| _ |/ _` |/ _` | '_ \\| __/ _` | '__| '__|\r\n| |_| | | | | | | | (_| | |_| | |_| | | | (_| | (_| | |_) | || (_| | | | | \r\n \\___/|_| |_| |_|_|\\__,_|\\__,_|\\__\\_| |_/\\__,_|\\__,_| .__/ \\__\\__,_|_| |_| \r\n | | \r\n |_| \r\n"); + } + + public static void ShowInformation() + { + Console.WriteLine("--------------------------[IP Leak Test]-----------------------------"); + var ipInfo = GetPublicIpAddressInfoAsync().GetAwaiter().GetResult(); + + if (ipInfo != null) { - Console.WriteLine("\r\n _ _ _ _ ___ _ _ \r\n| | | | | | | | / _ \\ | | | | \r\n| | | |_ __ ___ | | __ _ _ _| |_/ /_\\ \\ __| | __ _ _ __ | |_ __ _ _ __ _ __ \r\n| | | | '_ ` _ \\| |/ _` | | | | __| _ |/ _` |/ _` | '_ \\| __/ _` | '__| '__|\r\n| |_| | | | | | | | (_| | |_| | |_| | | | (_| | (_| | |_) | || (_| | | | | \r\n \\___/|_| |_| |_|_|\\__,_|\\__,_|\\__\\_| |_/\\__,_|\\__,_| .__/ \\__\\__,_|_| |_| \r\n | | \r\n |_| \r\n"); + Console.WriteLine($"Your Public IP Address is '{ipInfo.Ip}'"); + Console.WriteLine($"Hostname: {ipInfo.Hostname}"); + Console.WriteLine($"City: {ipInfo.City}"); + Console.WriteLine($"Region: {ipInfo.Region}"); + Console.WriteLine($"Country: {ipInfo.Country}"); + Console.WriteLine($"Provider: {ipInfo.Org}"); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Error: Could not retrieve public IP information."); + Console.ResetColor(); + } + + Console.WriteLine("--------------------------------------------------------------------"); + } + + + private static async Task GetPublicIpAddressInfoAsync() + { + using (var client = new HttpClient()) + { + client.Timeout = TimeSpan.FromSeconds(10); + + try + { + var response = await client.GetAsync("https://ipinfo.io/json"); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content); + } + catch + { + return null; + } } } } + +public class IpInfo +{ + [JsonPropertyName("ip")] + public string Ip { get; set; } + + [JsonPropertyName("hostname")] + public string Hostname { get; set; } + + [JsonPropertyName("city")] + public string City { get; set; } + + [JsonPropertyName("region")] + public string Region { get; set; } + + [JsonPropertyName("country")] + public string Country { get; set; } + + [JsonPropertyName("loc")] + public string Loc { get; set; } + + [JsonPropertyName("org")] + public string Org { get; set; } + + [JsonPropertyName("postal")] + public string Postal { get; set; } + + [JsonPropertyName("timezone")] + public string Timezone { get; set; } +} \ No newline at end of file From 74104c300e6b2182f5d87341ac3ccc4c03be39b9 Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sun, 9 Jun 2024 13:40:47 +0200 Subject: [PATCH 12/12] Create IpInfo.cs Move To Model --- UmlautAdaptarr/Models/IpInfo.cs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 UmlautAdaptarr/Models/IpInfo.cs diff --git a/UmlautAdaptarr/Models/IpInfo.cs b/UmlautAdaptarr/Models/IpInfo.cs new file mode 100644 index 0000000..10142c0 --- /dev/null +++ b/UmlautAdaptarr/Models/IpInfo.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace UmlautAdaptarr.Utilities; + +public class IpInfo +{ + [JsonPropertyName("ip")] + public string Ip { get; set; } + + [JsonPropertyName("hostname")] + public string Hostname { get; set; } + + [JsonPropertyName("city")] + public string City { get; set; } + + [JsonPropertyName("region")] + public string Region { get; set; } + + [JsonPropertyName("country")] + public string Country { get; set; } + + [JsonPropertyName("loc")] + public string Loc { get; set; } + + [JsonPropertyName("org")] + public string Org { get; set; } + + [JsonPropertyName("postal")] + public string Postal { get; set; } + + [JsonPropertyName("timezone")] + public string Timezone { get; set; } +} \ No newline at end of file