From f06a866a2f1403ca3bc06523ab202b2d4aea5b2e Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sat, 27 Apr 2024 18:48:43 +0200 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 b44c2947821315edf3b7757b4726a3188dd2456e Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 15 May 2024 20:40:18 +0200 Subject: [PATCH 09/34] Fix NoSpecialCharactersExceptHyphenAndUmlautsRegex not matching any special characters --- UmlautAdaptarr/Utilities/Extensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UmlautAdaptarr/Utilities/Extensions.cs b/UmlautAdaptarr/Utilities/Extensions.cs index 801fa3e..941da56 100644 --- a/UmlautAdaptarr/Utilities/Extensions.cs +++ b/UmlautAdaptarr/Utilities/Extensions.cs @@ -99,7 +99,7 @@ namespace UmlautAdaptarr.Utilities { if (removeUmlauts) { - return NoSpecialCharactersExceptHypenRegex().Replace(text, ""); + return NoSpecialCharactersExceptHyphenRegex().Replace(text, ""); } else { @@ -157,9 +157,9 @@ namespace UmlautAdaptarr.Utilities } [GeneratedRegex("[^a-zA-Z0-9 -]+", RegexOptions.Compiled)] - private static partial Regex NoSpecialCharactersExceptHypenRegex(); + private static partial Regex NoSpecialCharactersExceptHyphenRegex(); - [GeneratedRegex("[^a-zA-Z0-9 -öäüßÖÄÜß]+", RegexOptions.Compiled)] + [GeneratedRegex("[^a-zA-Z0-9 öäüßÖÄÜß-]+", RegexOptions.Compiled)] private static partial Regex NoSpecialCharactersExceptHyphenAndUmlautsRegex(); [GeneratedRegex(@"\s+")] From 265c098630539b4514f4ffa63ce571fe586fbf5d Mon Sep 17 00:00:00 2001 From: Felix Glang Date: Sun, 9 Jun 2024 12:27:26 +0200 Subject: [PATCH 10/34] 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 11/34] 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 12/34] 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 13/34] 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 From fd6a8581d8974a4e7ee6f7b4ba9bd93638f2d4a6 Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 4 Sep 2024 18:06:51 +0200 Subject: [PATCH 14/34] Also search for "GERMAN" if title ends with "Germany" and also match for title without "Germany" --- UmlautAdaptarr/Models/SearchItem.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs index 29fdabf..4e3239b 100644 --- a/UmlautAdaptarr/Models/SearchItem.cs +++ b/UmlautAdaptarr/Models/SearchItem.cs @@ -108,6 +108,20 @@ namespace UmlautAdaptarr.Models } + // if a german title ends with "Germany" (e.g. Good Luck Guys Germany) also add a search string that replaces Germany with GERMAN + // (e.g. Good Luck Guys GERMAN). This is because reality shows often have different formats in different countries with the same + // name. // also add a matching title without GERMAN + if (germanTitle?.EndsWith("germany", StringComparison.OrdinalIgnoreCase) ?? false) + { + TitleSearchVariations = [.. TitleSearchVariations, + .. + GenerateVariations( + (germanTitle[..^7] + "GERMAN").RemoveExtraWhitespaces(), + mediaType)]; + + allTitleVariations.AddRange(GenerateVariations(germanTitle[..^8].Trim(), mediaType)); + } + // If title contains ":" also match for "-" if (germanTitle?.Contains(':') ?? false) { From f886b17164aaad6c04979c2b6a26f155b9d3b66f Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 4 Sep 2024 19:00:58 +0200 Subject: [PATCH 15/34] Add FixBadReleaseNaming base concept --- .../Services/TitleMatchingService.cs | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index d229474..bd43337 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -209,16 +209,8 @@ namespace UmlautAdaptarr.Services // Clean up any leading separator from the suffix suffix = Regex.Replace(suffix, "^ +", ""); - // TODO EVALUTE! definitely make this optional - this adds GERMAN to the title is the title is german to make sure it's recognized as german - // can lead to problems with shows such as "dark" that have international dubs - /* - // Check if "german" is not in the original title, ignoring case - if (!Regex.IsMatch(originalTitle, "german", RegexOptions.IgnoreCase)) - { - // Insert "GERMAN" after the newTitlePrefix - newTitlePrefix += separator + "GERMAN"; - } - */ + // TODO add this when radarr is implemented + // FixBadReleaseNaming // Construct the new title with the original suffix var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : suffix.StartsWith(separator) ? suffix : $"{separator}{suffix}"); @@ -233,6 +225,50 @@ namespace UmlautAdaptarr.Services } } + private static string[] MissingGermanTagReleaseGroups = ["tvr"]; + private static string[] HEVCInsteadOfx265TagReleaseGroups = ["eisbaer"]; + private static string[] WrongTagsReleaseGroups = ["eisbaer"]; + private static string FixBadReleaseNaming(string title, string seperator, ILogger logger) + { + var releaseGroup = GetReleaseGroup(title); + if (MissingGermanTagReleaseGroups.Contains(releaseGroup)) + { + // Check if "german" is not in the title, ignoring case + if (!Regex.IsMatch(title, "german", RegexOptions.IgnoreCase)) + { + logger.LogInformation($"FixBadReleaseNaming - found missing GERMAN tag for {title}"); + // TODO not finished + // Insert "GERMAN" after the newTitlePrefix + //newTitlePrefix += separator + "GERMAN"; + } + } + + if (HEVCInsteadOfx265TagReleaseGroups.Contains(releaseGroup)) + { + if (!title.Contains("REMUX", StringComparison.InvariantCultureIgnoreCase)) + { + logger.LogInformation($"FixBadReleaseNaming - found HEVC instead of x265 for {title}"); + title = title.Replace("HEVC", "x265"); + } + } + + if (WrongTagsReleaseGroups.Contains(releaseGroup)) + { + if (title.Contains($"{seperator}RM{seperator}")) + { + logger.LogInformation($"FixBadReleaseNaming - found bad Tag RM instead of REMASTERED for {title}"); + title = title.Replace($"{seperator}RM{seperator}", $"{seperator}REMASTERED{seperator}"); + } + } + + return ""; + } + + private static string? GetReleaseGroup(string title) + { + return title.Contains('-') ? title[(title.LastIndexOf('-') + 1)..].Trim() : null; + } + private static string ReplaceSeperatorsWithSpace(string title) { // Replace all known separators with space for normalization From ce74044b9bb76858997e1a99ccca5f2da100fe8e Mon Sep 17 00:00:00 2001 From: Jonas F Date: Wed, 4 Sep 2024 19:16:37 +0200 Subject: [PATCH 16/34] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6593533..611087b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,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 | ✓ +| Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr) | ✓ | Radarr Support | Geplant | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant | From 4db26e374f45de160b9cdd5cb2293f22a3c098b6 Mon Sep 17 00:00:00 2001 From: Jonas F Date: Wed, 4 Sep 2024 19:17:13 +0200 Subject: [PATCH 17/34] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 611087b..1d8e5b6 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda | Releases mit deutschem Titel werden erkannt | ✓ | | Releases mit TVDB-Alias Titel werden erkannt | ✓ | | Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ | -| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | ✓ | | Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Usenet (newznab) Support |✓| | Torrent (torznab) Support |✓| | Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr) | ✓ -| Radarr Support | Geplant | +| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit | +| Radarr Support | in Arbeit | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Wünsche? | Vorschläge? | From 238bd9cc60aa4989671ba503f669ee5c50f9e975 Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 4 Sep 2024 19:30:31 +0200 Subject: [PATCH 18/34] Code cleanup --- UmlautAdaptarr/Program.cs | 5 +--- UmlautAdaptarr/Services/HttpProxyService.cs | 8 ++--- .../Services/TitleMatchingService.cs | 6 ++-- UmlautAdaptarr/UmlautAdaptarr.csproj | 6 ++-- UmlautAdaptarr/Utilities/Helper.cs | 30 ------------------- build_linux.bat | 4 +++ 6 files changed, 15 insertions(+), 44 deletions(-) create mode 100644 build_linux.bat diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 58c29b6..8123225 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -11,8 +11,6 @@ internal class Program { private static void Main(string[] args) { - - Helper.ShowLogo(); Helper.ShowInformation(); // TODO: @@ -26,11 +24,10 @@ internal class Program .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 + //.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // TODO - Not working currently .CreateLogger(); - builder.Services.AddSerilog(); // Add services to the container. diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 074a58a..0444883 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -10,7 +10,7 @@ namespace UmlautAdaptarr.Services private readonly ILogger _logger; private readonly int _proxyPort = 5006; // TODO move to appsettings.json private readonly IHttpClientFactory _clientFactory; - private HashSet _knownHosts = []; + private readonly HashSet _knownHosts = []; private readonly object _hostsLock = new object(); @@ -34,7 +34,7 @@ namespace UmlautAdaptarr.Services { using var clientStream = new NetworkStream(clientSocket, ownsSocket: true); var buffer = new byte[8192]; - var bytesRead = await clientStream.ReadAsync(buffer, 0, buffer.Length); + var bytesRead = await clientStream.ReadAsync(buffer); var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead); if (requestString.StartsWith("CONNECT")) @@ -129,8 +129,8 @@ namespace UmlautAdaptarr.Services var colonIndex = line.IndexOf(':'); if (colonIndex > 0) { - var key = line.Substring(0, colonIndex).Trim(); - var value = line.Substring(colonIndex + 1).Trim(); + var key = line[..colonIndex].Trim(); + var value = line[(colonIndex + 1)..].Trim(); headers[key] = value; } } diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index bd43337..60f4d21 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -225,9 +225,9 @@ namespace UmlautAdaptarr.Services } } - private static string[] MissingGermanTagReleaseGroups = ["tvr"]; - private static string[] HEVCInsteadOfx265TagReleaseGroups = ["eisbaer"]; - private static string[] WrongTagsReleaseGroups = ["eisbaer"]; + private static readonly string[] MissingGermanTagReleaseGroups = ["tvr"]; + private static readonly string[] HEVCInsteadOfx265TagReleaseGroups = ["eisbaer"]; + private static readonly string[] WrongTagsReleaseGroups = ["eisbaer"]; private static string FixBadReleaseNaming(string title, string seperator, ILogger logger) { var releaseGroup = GetReleaseGroup(title); diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index b03a7e2..1969466 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,13 +9,13 @@ - + - - + + diff --git a/UmlautAdaptarr/Utilities/Helper.cs b/UmlautAdaptarr/Utilities/Helper.cs index 9a796bd..ebd867a 100644 --- a/UmlautAdaptarr/Utilities/Helper.cs +++ b/UmlautAdaptarr/Utilities/Helper.cs @@ -56,33 +56,3 @@ public static class Helper } } } - -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 diff --git a/build_linux.bat b/build_linux.bat new file mode 100644 index 0000000..7b6e41c --- /dev/null +++ b/build_linux.bat @@ -0,0 +1,4 @@ +@echo off +dotnet publish -c Release -r linux-x64 --self-contained +'dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -p:IncludeAllContentForSelfExtract=true +pause \ No newline at end of file From 370e3ca06bb9567f789968e839233b05691b11fb Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 4 Sep 2024 19:39:15 +0200 Subject: [PATCH 19/34] Fix warnings --- .../Controllers/SearchController.cs | 2 +- UmlautAdaptarr/Models/IpInfo.cs | 18 ++++++------- UmlautAdaptarr/Models/SearchItem.cs | 2 +- UmlautAdaptarr/Providers/ArrClientBase.cs | 2 ++ UmlautAdaptarr/Services/HttpProxyService.cs | 10 +++---- .../Services/TitleMatchingService.cs | 10 +++---- UmlautAdaptarr/Utilities/Helper.cs | 26 +++++++++---------- .../Utilities/ServicesExtensions.cs | 18 +++++-------- 8 files changed, 41 insertions(+), 47 deletions(-) diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 90259d5..63f2f03 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -23,7 +23,7 @@ namespace UmlautAdaptarr.Controllers return NotFound($"{domain} is not a valid URL."); } - var initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult; + ContentResult? initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult; if (initialSearchResult == null) { return null; diff --git a/UmlautAdaptarr/Models/IpInfo.cs b/UmlautAdaptarr/Models/IpInfo.cs index 10142c0..6abaf27 100644 --- a/UmlautAdaptarr/Models/IpInfo.cs +++ b/UmlautAdaptarr/Models/IpInfo.cs @@ -5,29 +5,29 @@ namespace UmlautAdaptarr.Utilities; public class IpInfo { [JsonPropertyName("ip")] - public string Ip { get; set; } + public string? Ip { get; set; } [JsonPropertyName("hostname")] - public string Hostname { get; set; } + public string? Hostname { get; set; } [JsonPropertyName("city")] - public string City { get; set; } + public string? City { get; set; } [JsonPropertyName("region")] - public string Region { get; set; } + public string? Region { get; set; } [JsonPropertyName("country")] - public string Country { get; set; } + public string? Country { get; set; } [JsonPropertyName("loc")] - public string Loc { get; set; } + public string? Loc { get; set; } [JsonPropertyName("org")] - public string Org { get; set; } + public string? Org { get; set; } [JsonPropertyName("postal")] - public string Postal { get; set; } + public string? Postal { get; set; } [JsonPropertyName("timezone")] - public string Timezone { get; set; } + public string? Timezone { get; set; } } \ No newline at end of file diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs index 4e3239b..83e2d55 100644 --- a/UmlautAdaptarr/Models/SearchItem.cs +++ b/UmlautAdaptarr/Models/SearchItem.cs @@ -166,7 +166,7 @@ namespace UmlautAdaptarr.Models } } - private IEnumerable GenerateVariations(string? title, string mediaType) + private static IEnumerable GenerateVariations(string? title, string mediaType) { if (title == null) { diff --git a/UmlautAdaptarr/Providers/ArrClientBase.cs b/UmlautAdaptarr/Providers/ArrClientBase.cs index e192fae..8d2b9b8 100644 --- a/UmlautAdaptarr/Providers/ArrClientBase.cs +++ b/UmlautAdaptarr/Providers/ArrClientBase.cs @@ -5,7 +5,9 @@ namespace UmlautAdaptarr.Providers; public abstract class ArrClientBase : IArrApplication { +#pragma warning disable CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Erwägen Sie die Deklaration als Nullable. public string InstanceName; +#pragma warning restore CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Erwägen Sie die Deklaration als Nullable. public abstract Task> FetchAllItemsAsync(); public abstract Task FetchItemByExternalIdAsync(string externalId); public abstract Task FetchItemByTitleAsync(string title); diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 0444883..b50064b 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -11,8 +11,8 @@ namespace UmlautAdaptarr.Services private readonly int _proxyPort = 5006; // TODO move to appsettings.json private readonly IHttpClientFactory _clientFactory; private readonly HashSet _knownHosts = []; - private readonly object _hostsLock = new object(); - + private readonly object _hostsLock = new(); + private static readonly string[] newLineSeparator = ["\r\n"]; public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory) { @@ -123,7 +123,7 @@ namespace UmlautAdaptarr.Services { var headers = new Dictionary(); var headerString = Encoding.ASCII.GetString(buffer, 0, length); - var lines = headerString.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); + var lines = headerString.Split(newLineSeparator, StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines.Skip(1)) // Skip the request line { var colonIndex = line.IndexOf(':'); @@ -137,7 +137,7 @@ namespace UmlautAdaptarr.Services return headers; } - private (string host, int port) ParseTargetInfo(string requestLine) + private static (string host, int port) ParseTargetInfo(string requestLine) { var parts = requestLine.Split(' ')[1].Split(':'); return (parts[0], int.Parse(parts[1])); @@ -150,7 +150,7 @@ namespace UmlautAdaptarr.Services await Task.WhenAll(clientToTargetTask, targetToClientTask); } - private async Task RelayStream(NetworkStream input, NetworkStream output) + private static async Task RelayStream(NetworkStream input, NetworkStream output) { byte[] buffer = new byte[8192]; int bytesRead; diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 60f4d21..4d1101e 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -69,11 +69,11 @@ namespace UmlautAdaptarr.Services public void FindAndReplaceForBooksAndAudio(SearchItem searchItem, XElement? titleElement, string originalTitle) { var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); - var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); + var (foundMatch, bestStart, bestEndInOriginal) = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); - if (authorMatch.foundMatch && titleMatch.foundMatch) + if (authorMatch.foundMatch && foundMatch) { - int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, titleMatch.bestEndInOriginal); + int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, bestEndInOriginal); // Check and adjust for immediate following delimiter char[] delimiters = [' ', '-', '_', '.']; @@ -103,7 +103,7 @@ namespace UmlautAdaptarr.Services } - private (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle) + private static (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle) { bool found = false; int bestStart = int.MaxValue; @@ -131,7 +131,7 @@ namespace UmlautAdaptarr.Services } // Maps an index from the normalized string back to a corresponding index in the original string - private int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex) + private static int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex) { // Count non-special characters up to the given index in the normalized string int nonSpecialCharCount = 0; diff --git a/UmlautAdaptarr/Utilities/Helper.cs b/UmlautAdaptarr/Utilities/Helper.cs index ebd867a..002310e 100644 --- a/UmlautAdaptarr/Utilities/Helper.cs +++ b/UmlautAdaptarr/Utilities/Helper.cs @@ -38,21 +38,19 @@ public static class Helper private static async Task GetPublicIpAddressInfoAsync() { - using (var client = new HttpClient()) - { - client.Timeout = TimeSpan.FromSeconds(10); + 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; - } + 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; } } } diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 3e46e4e..7037172 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -35,17 +35,15 @@ public static class ServicesExtensions where TService : class, TInterface where TInterface : class { - try { 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 + // If we have no Single Instance, we try to parse for an Array var optionsArray = singleHost == null ? builder.Configuration.GetSection(sectionName).Get() : @@ -59,8 +57,7 @@ public static class ServicesExtensions foreach (var option in optionsArray) { - - GlobalInstanceOptionsValidator validator = new GlobalInstanceOptionsValidator(); + GlobalInstanceOptionsValidator validator = new GlobalInstanceOptionsValidator(); var results = validator.Validate(option as GlobalInstanceOptions); @@ -100,7 +97,7 @@ public static class ServicesExtensions } else { - Logger.LogWarning((prop.PropertyType + "No Support")); + Logger.LogWarning(prop.PropertyType + "No Support"); } } @@ -110,9 +107,9 @@ public static class ServicesExtensions return builder; } - catch (Exception e) + catch (Exception ex) { - Console.WriteLine("Error while Init UmlautAdaptrr"); + Console.WriteLine($"Error in AddServicesWithOptions: {ex.Message}"); throw; } @@ -133,11 +130,8 @@ public static class ServicesExtensions { 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( + var options = builder.Configuration.GetSection(sectionName).Get() ?? 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(); From b8575831bd3b9d334f9972d83865a13450dfc4e6 Mon Sep 17 00:00:00 2001 From: pcjones Date: Wed, 4 Sep 2024 19:40:02 +0200 Subject: [PATCH 20/34] Fix appsettings.json example --- UmlautAdaptarr/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 70e3e40..ed48d5e 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -37,7 +37,7 @@ { // Docker Environment Variables: // - Sonarr__1__Enabled: true (set to false to disable) - // - Sonarr__0__Name: Name of the Instance (Optional) + // - Sonarr__1__Name: Name of the Instance (Optional) // - Sonarr__1__Host: your_sonarr_host_url // - Sonarr__1__ApiKey: your_sonarr_api_key "Enabled": false, From 706199074d7a167cd561ad3fbc33b02e903b2ee9 Mon Sep 17 00:00:00 2001 From: Jonas F Date: Wed, 4 Sep 2024 20:04:42 +0200 Subject: [PATCH 21/34] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1d8e5b6..fbc7bc0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda | Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr) | ✓ | Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit | | Radarr Support | in Arbeit | +| Webinterface | Geplant | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Wünsche? | Vorschläge? | From 54fe1c0f89bbaf274efe7368ec6c0266b6ee0f2e Mon Sep 17 00:00:00 2001 From: pcjones Date: Thu, 5 Sep 2024 14:24:42 +0200 Subject: [PATCH 22/34] Remove proxy support --- UmlautAdaptarr/Options/Proxy.cs | 27 ----------- UmlautAdaptarr/Options/ProxyOptions.cs | 32 ------------- UmlautAdaptarr/Program.cs | 2 - UmlautAdaptarr/Utilities/ProxyExtension.cs | 53 ---------------------- UmlautAdaptarr/appsettings.json | 14 ------ 5 files changed, 128 deletions(-) delete mode 100644 UmlautAdaptarr/Options/Proxy.cs delete mode 100644 UmlautAdaptarr/Options/ProxyOptions.cs delete mode 100644 UmlautAdaptarr/Utilities/ProxyExtension.cs diff --git a/UmlautAdaptarr/Options/Proxy.cs b/UmlautAdaptarr/Options/Proxy.cs deleted file mode 100644 index 63aaec3..0000000 --- a/UmlautAdaptarr/Options/Proxy.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace UmlautAdaptarr.Options; - -/// -/// Represents options for proxy configuration. -/// -public class Proxy -{ - /// - /// Gets or sets a value indicating whether to use a proxy. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the address of the proxy. - /// - public string? Address { get; set; } - - /// - /// Gets or sets the username for proxy authentication. - /// - public string? Username { get; set; } - - /// - /// Gets or sets the password for proxy authentication. - /// - public string? Password { get; set; } -} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ProxyOptions.cs b/UmlautAdaptarr/Options/ProxyOptions.cs deleted file mode 100644 index 1bea2c6..0000000 --- a/UmlautAdaptarr/Options/ProxyOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace UmlautAdaptarr.Options; - -/// -/// Represents options for proxy configuration. -/// -public class ProxyOptions -{ - /// - /// Gets or sets a value indicating whether to use a proxy. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the address of the proxy. - /// - public string? Address { get; set; } - - /// - /// Gets or sets the username for proxy authentication. - /// - public string? Username { get; set; } - - /// - /// Gets or sets the password for proxy authentication. - /// - public string? Password { get; set; } - - /// - /// Bypass Local Ip Addresses , Proxy will ignore local Ip Addresses - /// - public bool BypassOnLocal { get; set; } -} \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 8123225..85149de 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -39,8 +39,6 @@ internal class Program DecompressionMethods.Brotli }; - var proxyOptions = configuration.GetSection("Proxy").Get(); - handler.ConfigureProxy(proxyOptions); return handler; }); diff --git a/UmlautAdaptarr/Utilities/ProxyExtension.cs b/UmlautAdaptarr/Utilities/ProxyExtension.cs deleted file mode 100644 index 6a448ba..0000000 --- a/UmlautAdaptarr/Utilities/ProxyExtension.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Net; -using UmlautAdaptarr.Options; - -namespace UmlautAdaptarr.Utilities -{ - /// - /// Extension methods for configuring proxies. - /// - public static class ProxyExtension - { - /// - /// Logger instance for logging proxy configurations. - /// - private static ILogger Logger = GlobalStaticLogger.Logger; - - /// - /// Configures the proxy settings for the provided HttpClientHandler instance. - /// - /// The HttpClientHandler instance to configure. - /// ProxyOptions options to be used for configuration. - /// The configured HttpClientHandler instance. - public static HttpClientHandler ConfigureProxy(this HttpClientHandler handler, ProxyOptions? proxyOptions) - { - try - { - if (proxyOptions != null && proxyOptions.Enabled) - { - Logger.LogInformation("Use Proxy {0}", proxyOptions.Address); - handler.UseProxy = true; - handler.Proxy = new WebProxy(proxyOptions.Address, proxyOptions.BypassOnLocal); - - if (!string.IsNullOrEmpty(proxyOptions.Username) && !string.IsNullOrEmpty(proxyOptions.Password)) - { - Logger.LogInformation("Use Proxy Credentials from User {0}", proxyOptions.Username); - handler.DefaultProxyCredentials = - new NetworkCredential(proxyOptions.Username, proxyOptions.Password); - } - } - else - { - Logger.LogDebug("No proxy was set"); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error occurred while configuring proxy, no Proxy will be used!"); - } - - return handler; - } - } -} \ No newline at end of file diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index ed48d5e..b9b4ba0 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -64,19 +64,5 @@ "Enabled": false, "Host": "your_readarr_host_url", "ApiKey": "your_readarr_api_key" - }, - - // Docker Environment Variables: - // - Proxy__Enabled: true (set to false to disable) - // - Proxy__Address: http://yourproxyaddress:port - // - Proxy__Username: your_proxy_username - // - Proxy__Password: your_proxy_password - // - Proxy__BypassOnLocal: true (set to false to not bypass local IP addresses) - "Proxy": { - "Enabled": false, - "Address": "http://yourproxyaddress:port", - "Username": "your_proxy_username", - "Password": "your_proxy_password", - "BypassOnLocal": true } } From d9087e2fe536643c49b4ac99f7db3cae146b0faa Mon Sep 17 00:00:00 2001 From: Jonas F Date: Fri, 6 Sep 2024 20:46:50 +0200 Subject: [PATCH 23/34] Remove proxy environment variables from compose --- docker-compose.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3a12ed0..de44f35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,8 +23,3 @@ services: - LIDARR__ENABLED=false - LIDARR__HOST=http://localhost:8686 - LIDARR__APIKEY=APIKEY - #- Proxy__Enabled: false - #- Proxy__Address: http://yourproxyaddress:port - #- Proxy__Username: your_proxy_username - #- Proxy__Password: your_proxy_password - #- Proxy__BypassOnLocal: true (set to false to not bypass local IP addresses) From abff4953e80d162e4563730b6cbbb81e6c349a6b Mon Sep 17 00:00:00 2001 From: pcjones Date: Tue, 10 Sep 2024 17:08:35 +0200 Subject: [PATCH 24/34] Read port from appsettings --- UmlautAdaptarr/Services/HttpProxyService.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index b50064b..2017db4 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -12,11 +12,13 @@ namespace UmlautAdaptarr.Services private readonly IHttpClientFactory _clientFactory; private readonly HashSet _knownHosts = []; private readonly object _hostsLock = new(); + private readonly IConfiguration _configuration; private static readonly string[] newLineSeparator = ["\r\n"]; - public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory) + public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory, IConfiguration configuration) { _logger = logger; + _configuration = configuration; _clientFactory = clientFactory; _knownHosts.Add("prowlarr.servarr.com"); } @@ -91,7 +93,10 @@ namespace UmlautAdaptarr.Services } } - var modifiedUri = $"http://localhost:5005/_/{uri.Host}{uri.PathAndQuery}"; // TODO read port from appsettings? + var url = _configuration["Kestrel:Endpoints:Http:Url"]; + var port = new Uri(url).Port; + + var modifiedUri = $"http://localhost:{port}/_/{uri.Host}{uri.PathAndQuery}"; using var client = _clientFactory.CreateClient(); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri); httpRequestMessage.Headers.Add("User-Agent", userAgent); From fcf85a5ad1bd179f8ea079987cf44469a8239c0b Mon Sep 17 00:00:00 2001 From: pcjones Date: Tue, 10 Sep 2024 17:38:44 +0200 Subject: [PATCH 25/34] AllowAutoRedirect for BeReachable check --- UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index f37dc8b..239857e 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -38,7 +38,9 @@ public class GlobalInstanceOptionsValidator : AbstractValidator Date: Tue, 10 Sep 2024 17:52:36 +0200 Subject: [PATCH 26/34] Fix reachable check for ultra cc seedbox --- .../GlobalInstanceOptionsValidator.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 239857e..bbd91c9 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -40,16 +40,31 @@ public class GlobalInstanceOptionsValidator : AbstractValidator Date: Tue, 10 Sep 2024 18:01:34 +0200 Subject: [PATCH 27/34] Fix reachable check --- UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index bbd91c9..eca7e94 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -59,6 +59,10 @@ public class GlobalInstanceOptionsValidator : AbstractValidator Date: Sun, 22 Sep 2024 21:14:35 +0200 Subject: [PATCH 28/34] Fix season matching pattern to match up to 4 digit seasons/episodes --- UmlautAdaptarr/Services/TitleMatchingService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 4d1101e..2eb7dc2 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -196,9 +196,9 @@ namespace UmlautAdaptarr.Services // Workaround for the rare case of e.g. "Frieren: Beyond Journey's End" that also has the alias "Frieren" if (expectedTitle!.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) { - // See if we already matched the whole title by checking if S01E01 pattern is coming next to avoid false positives + // See if we already matched the whole title by checking if S01E01/S2024E123 pattern is coming next to avoid false positives // - that won't help with movies but with tv shows - var seasonMatchingPattern = $"^{separator}S\\d{{1,2}}E\\d{{1,2}}"; + var seasonMatchingPattern = $"^{separator}S\\d{{1,4}}E\\d{{1,4}}"; if (!Regex.IsMatch(suffix, seasonMatchingPattern)) { logger.LogWarning($"TitleMatchingService - Didn't rename: '{originalTitle}' because the expected title '{expectedTitle}' starts with the variation '{variation}'"); From 4a628f7c66b1ca58dbf4e53bb70f0b87afb9e33d Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 30 Sep 2024 14:02:30 +0200 Subject: [PATCH 29/34] Add missing StringComparison.OrdinalIgnoreCase --- UmlautAdaptarr/Services/TitleMatchingService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 2eb7dc2..802c92b 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -314,7 +314,7 @@ namespace UmlautAdaptarr.Services { return "book"; } - else if (category == "3000" || category.StartsWith("Audio")) + else if (category == "3000" || category.StartsWith("Audio", StringComparison.OrdinalIgnoreCase)) { return "audio"; } From 94b2cf94c43d8ac057ad7bb71b4e3ea51261bd3b Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 30 Sep 2024 14:03:18 +0200 Subject: [PATCH 30/34] Don't spam the log with debug info --- UmlautAdaptarr/Program.cs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 85149de..0e6328b 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -17,16 +17,7 @@ internal class Program // add option to sort by nzb age 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")) // TODO - Not working currently - .CreateLogger(); - + ConfigureLogger(configuration); builder.Services.AddSerilog(); @@ -99,4 +90,24 @@ internal class Program new { t = new TRouteConstraint("search") }); app.Run(); } + + private static void ConfigureLogger(ConfigurationManager configuration) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .WriteTo.Console(outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + +#if RELEASE + .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Mvc")) + .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Routing")) + .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Diagnostics")) + .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Hosting")) +#endif + + // TODO workaround to not log api keys + .Filter.ByExcluding(Matching.FromSource("System.Net.Http.HttpClient")) + .Filter.ByExcluding(Matching.FromSource("Microsoft.Extensions.Http.DefaultHttpClientFactory")) + //.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // TODO - Not working currently + .CreateLogger(); + } } \ No newline at end of file From fde9b0a5dec0e031646a0bf5d72477060fc36074 Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 30 Sep 2024 14:03:36 +0200 Subject: [PATCH 31/34] Remove unnecessary import --- UmlautAdaptarr/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 0e6328b..c6e42f4 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,7 +1,6 @@ using System.Net; using Serilog; using Serilog.Filters; -using UmlautAdaptarr.Options; using UmlautAdaptarr.Routing; using UmlautAdaptarr.Services; using UmlautAdaptarr.Services.Factory; From fc7c0bde281a2e1fb6c362a91bed0b54f8d9eb1e Mon Sep 17 00:00:00 2001 From: Jonas F Date: Fri, 11 Oct 2024 18:36:17 +0200 Subject: [PATCH 32/34] Add star history to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index fbc7bc0..e28b670 100644 --- a/README.md +++ b/README.md @@ -132,3 +132,7 @@ Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke - TV Metadata source: https://thetvdb.com - Movie Metadata source: https://themoviedb.org - Licenses: TODO + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=pcjones/umlautadaptarr&type=Date)](https://star-history.com/#pcjones/umlautadaptarr&Date) From 5487009306e122a8d0764aa3cb19d0f7911555cd Mon Sep 17 00:00:00 2001 From: Jonas F Date: Fri, 11 Oct 2024 19:24:41 +0200 Subject: [PATCH 33/34] Update README.md --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e28b670..3a0e93f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # UmlautAdaptarr -## English description coming soon + A tool to work around Sonarr, Radarr, Lidarr and Readarrs problems with foreign languages. + +## Detailed English description coming soon ## Beschreibung -Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen! - -Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr, Readarr und Lidarr schon Auswirkungen (abgesehen vom Caching). UmlautAdaptarr löst mehrere Probleme: -- Releases mit Umlauten werden grundsätzlich nicht korrekt von den *Arrs importiert -- Releases mit Umlauten werden oft nicht korrekt gefunden (*Arrs suchen nach "o" statt "ö" & es fehlt häufig die korrekte Zuordnung zur Serie/zum Film beim Indexer) +- Releases mit Umlauten werden grundsätzlich nicht korrekt von den *arrs importiert +- Releases mit Umlauten werden oft nicht korrekt gefunden (*arrs suchen nach "o" statt "ö" & es fehlt häufig die korrekte Zuordnung zur Serie/zum Film beim Indexer) - Sonarr & Radarr erwarten immer den englischen Titel von https://thetvdb.com/ bzw. https://www.themoviedb.org/. Das führt bei deutschen Produktionen oder deutschen Übersetzungen oft zu Problemen - falls die *arrs schon mal etwas mit der Meldung `Found matching series/movie via grab history, but release was matched to series by ID. Automatic import is not possible/` nicht importiert haben, dann war das der Grund. - Releases mit schlechtem Naming (z.B. von der Group TvR die kein "GERMAN" in den Releasename tun) werden korrigiert, so dass Sonarr&Radarr diese korrekt erkennen (optional) - Zusätzlich werden einige andere Fehler behoben, die häufig dazu führen, dass Titel nicht erfolgreich gefunden, geladen oder importiert werden. @@ -24,21 +23,20 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda | Feature | Status | |-------------------------------------------------------------------|---------------| -| Prowlarr & NZB Hydra Support | ✓| -| Sonarr Support | ✓ | -| Lidarr Support | ✓| -| Readarr Support | ✓ | -| Releases mit deutschem Titel werden erkannt | ✓ | -| Releases mit TVDB-Alias Titel werden erkannt | ✓ | -| Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ | -| Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ | -| Usenet (newznab) Support |✓| -| Torrent (torznab) Support |✓| -| Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr) | ✓ -| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit | -| Radarr Support | in Arbeit | -| Webinterface | Geplant | -| Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | +| Prowlarr & NZB Hydra Support |✓ | +| Sonarr Support |✓ | +| Lidarr Support |✓ | +| Readarr Support |✓ | +| Releases mit deutschem Titel werden erkannt |✓ | +| Releases mit TVDB-Alias Titel werden erkannt |✓ | +| Korrekte Suche und Erkennung von Titel mit Umlauten |✓ | +| Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriff |✓ | +| Usenet (newznab) Support |✓ | +| Torrent (torznab) Support |✓ | +| Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr)|✓ | +| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit| +| Radarr Support | in Arbeit | +| Webinterface | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Wünsche? | Vorschläge? | @@ -48,13 +46,14 @@ Momentan ist Docker dafür nötig, wer kein Docker nutzt muss sich noch etwas ge [Link zum Docker Image](https://hub.docker.com/r/pcjones/umlautadaptarr) -Nicht benötigte Umgebungsvariablen, z.B. wenn Readarr oder Lidarr nicht benötigt werden, können entfernt werden. +Nicht benötigte Umgebungsvariablen, z.B. falls Readarr oder Lidarr nicht genutzt werden, können entfernt werden. ### Konfiguration in Prowlarr (**empfohlen**) Das ist die **empfohlene** Methode um den UmlautAdaptarr einzurichten. Sie hat den Vorteil, dass es, sofern man mehrere Indexer nutzt, keinen Geschwindigkeitsverlust bei der Suche geben sollte. -1) In Prowlarr: Settings>Indexers bzw. Einstellungen>Indexer öffnen -2) Lege einen neuen HTTP-Proxy an: +1) Setze die benötigten [Docker Umgebungsvariablen](https://hub.docker.com/r/pcjones/umlautadaptarr) in deiner docker-compose Datei bzw. in deinem docker run Befehl +2) In Prowlarr: Settings>Indexers bzw. Einstellungen>Indexer öffnen +3) Lege einen neuen HTTP-Proxy an: ![Image](https://github.com/PCJones/UmlautAdaptarr/assets/377223/b97418d8-d972-4e3c-9d2f-3a830a5ac0a3) @@ -63,19 +62,20 @@ Das ist die **empfohlene** Methode um den UmlautAdaptarr einzurichten. Sie hat d - Tag: `umlautadaptarr` - Host: Je nachdem, wie deine Docker-Konfiguration ist, kann es sein, dass du entweder `umlautadaptarr` oder `localhost`, oder ggf. die IP des Host setzen musst. Probiere es sonst einfach aus, indem du auf Test klickst. - Die Username- und Passwort-Felder können leergelassen werden. -3) Gehe zur Indexer-Übersichtsseite -4) Für alle Indexer/Tracker, die den UmlautAdaptarr nutzen sollen: +4) Gehe zur Indexer-Übersichtsseite +5) Für alle Indexer/Tracker, die den UmlautAdaptarr nutzen sollen: ![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/3daea3f1-7c7b-4982-84e2-ea6a42d90fba) - Füge den `umlautadaptarr` Tag hinzu - **Wichtig:** Ändere die URL von `https` zu `http`. (Dies ist erforderlich, damit der UmlautAdaptarr die Anfragen **lokal** abfangen kann. **Ausgehende** Anfragen an den Indexer verwenden natürlich weiterhin https). -5) Klicke danach auf `Test All Indexers` bzw `Alle Indexer Testen`. Falls du irgendwo noch `https` statt `http` stehen hast, sollte in den UmlautAdaptarr logs eine Warnung auftauchen. Mindestens solltest du aber noch ein zweites Mal alle Indexer durchgehen und überprüfen, ob überall `http` eingestellt ist - Indexer, bei denen noch `https` steht, werden nämlich einwandfrei funktionieren - allerdings ohne, dass der UmlautAdaptarr bei diesen wirken kann. +6) Klicke danach auf `Test All Indexers` bzw `Alle Indexer Testen`. Falls du irgendwo noch `https` statt `http` stehen hast, sollte in den UmlautAdaptarr logs eine Warnung auftauchen. Mindestens solltest du aber noch ein zweites Mal alle Indexer durchgehen und überprüfen, ob überall `http` eingestellt ist - Indexer, bei denen noch `https` steht, werden nämlich einwandfrei funktionieren - allerdings ohne, dass der UmlautAdaptarr bei diesen wirken kann. ### Konfiguration in Sonarr/Radarr oder Prowlarr ohne Proxy Falls du kein Prowlarr nutzt oder nur 1-3 Indexer nutzt, kannst du diese alternative Konfigurationsmöglichkeit nutzen. -Dafür musst du einfach nur alle Indexer, bei denen der UmlautAdaptarr greifen soll, bearbeiten: +1) Setze die benötigten [Docker Umgebungsvariablen](https://hub.docker.com/r/pcjones/umlautadaptarr) in deiner docker-compose Datei bzw. in deinem docker run Befehl +2) Bearbeite alle Indexer, bei denen der UmlautAdaptarr greifen soll, wie folgt: Am Beispiel von sceneNZBs: From 4e030168eea830d1b5d85c5d018982d56e37caf3 Mon Sep 17 00:00:00 2001 From: Jonas F Date: Fri, 11 Oct 2024 19:47:08 +0200 Subject: [PATCH 34/34] Update docker-compose.yml --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index de44f35..7c97b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,3 +23,12 @@ services: - LIDARR__ENABLED=false - LIDARR__HOST=http://localhost:8686 - LIDARR__APIKEY=APIKEY + ### example for multiple instances of same type + #- SONARR__0__NAME=NAME 1 (optional) + #- SONARR__0__ENABLED=false + #- SONARR__0__HOST=http://localhost:8989 + #- SONARR__0__APIKEY=APIKEY + #- SONARR__1__NAME=NAME 2 (optional) + #- SONARR__1__ENABLED=false + #- SONARR__1__HOST=http://localhost:8989 + #- SONARR__1__APIKEY=APIKEY