diff --git a/README.md b/README.md index 316c59e..f02cd50 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,21 @@ ## English description coming soon -## Erste Testversion +## 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). -Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. - -[Link zum Docker Image](https://hub.docker.com/r/pcjones/umlautadaptarr) - -Zusätzlich müsst ihr in Sonarr oder Prowlarr einen neuen Indexer hinzufügen (für jeden Indexer, bei dem UmlautAdapdarr greifen soll). - -Am Beispiel von sceneNZBs: - -![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/07c7ca45-e0e5-4a82-af63-365bb23c55c9) - -Also alles wie immer, nur dass ihr als API-URL nicht direkt z.B. `https://scenenzbs.com` eingebt, sondern -`http://localhost:5005/_/scenenzbs.com` - -Den API-Key müsst ihr natürlich auch ganz normal setzen. - -## Was macht UmlautAdaptarr überhaupt? 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) - 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. +- Zusätzlich werden einige andere Fehler behoben, die häufig dazu führen, dass Titel nicht erfolgreich gefunden, geladen oder importiert werden. -# Wie macht UmlautAdaptarr das? +## Wie macht UmlautAdaptarr das? UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten. Am Ende werden die gefundenen Releases immer so umbenannt, dass die Arrs sie einwandfrei erkennen. -Einige Beispiele findet ihr unter Features. +Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAdaptarr/edit/develop/README.md#beispiel-funktionalit%C3%A4t). ## Features @@ -45,7 +30,7 @@ Einige Beispiele findet ihr unter Features. | 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 5 Minuten zur Reduzierung der API-Zugriffe | ✓ | +| Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Usenet (newznab) Support |✓| | Torrent (torznab) Support |✓| | Radarr Support | Geplant | @@ -53,6 +38,50 @@ Einige Beispiele findet ihr unter Features. | Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Wünsche? | Vorschläge? | + +## Installation +Momentan ist Docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. Eine Unraid-App gibt es auch, einfach nach `umlautadaptarr` suchen. + +[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. + +### 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: + +![Image](https://github.com/PCJones/UmlautAdaptarr/assets/377223/b97418d8-d972-4e3c-9d2f-3a830a5ac0a3) + +- Name: UmlautAdaptarr HTTP Proxy (Beispiel) +- Port: `5006` (Port beachten!) +- 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: + +![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. + +### 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: + +Am Beispiel von sceneNZBs: + +![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/07c7ca45-e0e5-4a82-af63-365bb23c55c9) + +Also alles wie immer, nur dass als API-URL nicht direkt z.B. `https://scenenzbs.com` gesetzt wird, sondern +`http://localhost:5005/_/scenenzbs.com` + +Der API-Key muss natürlich auch ganz normal gesetzt werden. + ## Beispiel-Funktionalität In den Klammern am Ende des Releasenamens (Bild 2 & 4) steht zu Anschauungszwecken der deutsche Titel der vorher nicht gefunden bzw. akzeptiert wurde. Das bleibt natürlich nicht so ;) @@ -66,7 +95,7 @@ In den Klammern am Ende des Releasenamens (Bild 2 & 4) steht zu Anschauungszweck **Vorher:** Es werden nur Releases mit dem englischen Titel der Serie gefunden ![Vorherige Suche, englische Titel](https://github.com/PCJones/UmlautAdaptarr/assets/377223/ed7ca0fa-ac36-4584-87ac-b29f32dd9ace) -**Jetzt:** Es werden auch Titel mit dem deutschen Namen gefunden :D (haben nicht alle Suchergebnisse auf den Screenshot gepasst) +**Jetzt:** Es werden auch Titel mit dem deutschen Namen gefunden :D ![Jetzige Suche, deutsche und englische Titel](https://github.com/PCJones/UmlautAdaptarr/assets/377223/1c2dbe1a-5943-4fc4-91ef-29708082900e) diff --git a/UmlautAdaptarr/Controllers/CapsController.cs b/UmlautAdaptarr/Controllers/CapsController.cs index 0d46837..9dc8fc3 100644 --- a/UmlautAdaptarr/Controllers/CapsController.cs +++ b/UmlautAdaptarr/Controllers/CapsController.cs @@ -6,9 +6,9 @@ namespace UmlautAdaptarr.Controllers { - public class CapsController(ProxyService proxyService) : ControllerBase + public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase { - private readonly ProxyService _proxyService = proxyService; + private readonly ProxyRequestService _proxyRequestService = proxyRequestService; [HttpGet] public async Task Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) @@ -20,7 +20,7 @@ public async Task Caps([FromRoute] string options, [FromRoute] st var requestUrl = UrlUtilities.BuildUrl(domain, "caps", apikey); - var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); + var responseMessage = await _proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl); var content = await responseMessage.Content.ReadAsStringAsync(); var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 98939c3..90259d5 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -6,7 +6,7 @@ namespace UmlautAdaptarr.Controllers { - public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase + public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService) : ControllerBase { // TODO evaluate if this should be set to true by default private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true; @@ -96,7 +96,7 @@ protected async Task BaseSearch(string options, private async Task PerformSingleSearchRequest(string domain, IDictionary queryParameters) { var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); - var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); + var responseMessage = await proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl); var content = await responseMessage.Content.ReadAsStringAsync(); var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? @@ -130,7 +130,7 @@ public async Task AggregateSearchResults( { queryParameters["q"] = titleVariation; // Replace the "q" parameter for each variation var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); - var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); + var responseMessage = await proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl); var content = await responseMessage.Content.ReadAsStringAsync(); // Only update encoding from the first response @@ -152,9 +152,9 @@ public async Task AggregateSearchResults( } } - public class SearchController(ProxyService proxyService, + public class SearchController(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, - SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService) + SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyRequestService, titleMatchingService) { public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"]; diff --git a/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs b/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs new file mode 100644 index 0000000..dc7df56 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs @@ -0,0 +1,23 @@ +namespace UmlautAdaptarr.Options.ArrOptions +{ + /// + /// Base Options for ARR applications + /// + public class ArrApplicationBaseOptions + { + /// + /// Indicates whether the Arr application is enabled. + /// + public bool Enabled { get; set; } + + /// + /// The host of the ARR application. + /// + public string Host { get; set; } + + /// + /// The API key of the ARR application. + /// + public string ApiKey { get; set; } + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs new file mode 100644 index 0000000..d5c85e2 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/LidarrInstanceOptions.cs @@ -0,0 +1,9 @@ +namespace UmlautAdaptarr.Options.ArrOptions +{ + /// + /// Lidarr Options + /// + public class LidarrInstanceOptions : ArrApplicationBaseOptions + { + } +} diff --git a/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs new file mode 100644 index 0000000..530e428 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/ReadarrInstanceOptions.cs @@ -0,0 +1,9 @@ +namespace UmlautAdaptarr.Options.ArrOptions +{ + /// + /// Readarr Options + /// + public class ReadarrInstanceOptions : ArrApplicationBaseOptions + { + } +} diff --git a/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs b/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs new file mode 100644 index 0000000..cacc6c3 --- /dev/null +++ b/UmlautAdaptarr/Options/ArrOptions/SonarrInstanceOptions.cs @@ -0,0 +1,9 @@ +namespace UmlautAdaptarr.Options.ArrOptions +{ + /// + /// Sonarr Options + /// + public class SonarrInstanceOptions : ArrApplicationBaseOptions + { + } +} diff --git a/UmlautAdaptarr/Options/GlobalOptions.cs b/UmlautAdaptarr/Options/GlobalOptions.cs new file mode 100644 index 0000000..51452a5 --- /dev/null +++ b/UmlautAdaptarr/Options/GlobalOptions.cs @@ -0,0 +1,18 @@ +namespace UmlautAdaptarr.Options +{ + /// + /// Global options for the UmlautAdaptarr application. + /// + public class GlobalOptions + { + /// + /// The host of the UmlautAdaptarr API. + /// + public string UmlautAdaptarrApiHost { get; set; } + + /// + /// The User-Agent string used in HTTP requests. + /// + public string UserAgent { get; set; } + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Options/Proxy.cs b/UmlautAdaptarr/Options/Proxy.cs new file mode 100644 index 0000000..63aaec3 --- /dev/null +++ b/UmlautAdaptarr/Options/Proxy.cs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..1bea2c6 --- /dev/null +++ b/UmlautAdaptarr/Options/ProxyOptions.cs @@ -0,0 +1,32 @@ +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 5a0818e..b1b4eae 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,8 +1,8 @@ -using Microsoft.Extensions.Configuration; using System.Net; -using UmlautAdaptarr.Providers; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Routing; using UmlautAdaptarr.Services; +using UmlautAdaptarr.Utilities; internal class Program { @@ -24,11 +24,14 @@ private static void Main(string[] args) AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli }; + var proxyOptions = configuration.GetSection("Proxy").Get(); + handler.ConfigureProxy(proxyOptions); return handler; }); builder.Services.AddMemoryCache(options => { + // TODO cache size limit? option? //options.SizeLimit = 20000; }); @@ -46,19 +49,20 @@ private static void Main(string[] args) builder.Services.AddControllers(); builder.Services.AddHostedService(); - builder.Services.AddSingleton(); + builder.AddTitleLookupService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.AddSonarrSupport(); + builder.AddLidarrSupport(); + builder.AddReadarrSupport(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var app = builder.Build(); + GlobalStaticLogger.Initialize(app.Services.GetService()!); app.UseHttpsRedirection(); - app.UseAuthorization(); app.MapControllerRoute(name: "caps", diff --git a/UmlautAdaptarr/Providers/ArrClientFactory.cs b/UmlautAdaptarr/Providers/ArrClientFactory.cs new file mode 100644 index 0000000..68f92e5 --- /dev/null +++ b/UmlautAdaptarr/Providers/ArrClientFactory.cs @@ -0,0 +1,17 @@ +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 de953f7..cde2677 100644 --- a/UmlautAdaptarr/Providers/LidarrClient.cs +++ b/UmlautAdaptarr/Providers/LidarrClient.cs @@ -1,7 +1,9 @@ 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.Services; using UmlautAdaptarr.Utilities; @@ -9,13 +11,11 @@ namespace UmlautAdaptarr.Providers { public class LidarrClient( IHttpClientFactory clientFactory, - IConfiguration configuration, CacheService cacheService, IMemoryCache cache, - ILogger logger) : ArrClientBase() + ILogger logger, IOptions options) : ArrClientBase() { - private readonly string _lidarrHost = configuration.GetValue("LIDARR_HOST") ?? throw new ArgumentException("LIDARR_HOST environment variable must be set"); - private readonly string _lidarrApiKey = configuration.GetValue("LIDARR_API_KEY") ?? throw new ArgumentException("LIDARR_API_KEY environment variable must be set"); + public LidarrInstanceOptions LidarrOptions { get; } = options.Value; private readonly string _mediaType = "audio"; public override async Task> FetchAllItemsAsync() @@ -25,7 +25,7 @@ public override async Task> FetchAllItemsAsync() try { - var lidarrArtistsUrl = $"{_lidarrHost}/api/v1/artist?apikey={_lidarrApiKey}"; + 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); @@ -40,7 +40,7 @@ public override async Task> FetchAllItemsAsync() { var artistId = (int)artist.id; - var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}"; + 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 @@ -135,7 +135,7 @@ public override async Task> FetchAllItemsAsync() { try { - // this should never be called at the moment + // this should never be called at the moment throw new NotImplementedException(); } catch (Exception ex) diff --git a/UmlautAdaptarr/Providers/ReadarrClient.cs b/UmlautAdaptarr/Providers/ReadarrClient.cs index df60c88..ce4a772 100644 --- a/UmlautAdaptarr/Providers/ReadarrClient.cs +++ b/UmlautAdaptarr/Providers/ReadarrClient.cs @@ -1,7 +1,9 @@ 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.Services; using UmlautAdaptarr.Utilities; @@ -9,13 +11,13 @@ namespace UmlautAdaptarr.Providers { public class ReadarrClient( IHttpClientFactory clientFactory, - IConfiguration configuration, CacheService cacheService, IMemoryCache cache, + IOptions options, ILogger logger) : ArrClientBase() { - private readonly string _readarrHost = configuration.GetValue("READARR_HOST") ?? throw new ArgumentException("READARR_HOST environment variable must be set"); - private readonly string _readarrApiKey = configuration.GetValue("READARR_API_KEY") ?? throw new ArgumentException("READARR_API_KEY environment variable must be set"); + + public ReadarrInstanceOptions ReadarrOptions { get; } = options.Value; private readonly string _mediaType = "book"; public override async Task> FetchAllItemsAsync() @@ -25,7 +27,7 @@ public override async Task> FetchAllItemsAsync() try { - var readarrAuthorUrl = $"{_readarrHost}/api/v1/author?apikey={_readarrApiKey}"; + 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); @@ -40,7 +42,7 @@ public override async Task> FetchAllItemsAsync() { var authorId = (int)author.id; - var readarrBookUrl = $"{_readarrHost}/api/v1/book?authorId={authorId}&apikey={_readarrApiKey}"; + 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)}"); diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index 11cc622..b9d9ef6 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -1,5 +1,7 @@ -using Newtonsoft.Json; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; using UmlautAdaptarr.Models; +using UmlautAdaptarr.Options.ArrOptions; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; @@ -7,12 +9,11 @@ namespace UmlautAdaptarr.Providers { public class SonarrClient( IHttpClientFactory clientFactory, - IConfiguration configuration, TitleApiService titleService, + IOptions options, ILogger logger) : ArrClientBase() { - private readonly string _sonarrHost = configuration.GetValue("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set"); - private readonly string _sonarrApiKey = configuration.GetValue("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set"); + public SonarrInstanceOptions SonarrOptions { get; } = options.Value; private readonly string _mediaType = "tv"; public override async Task> FetchAllItemsAsync() @@ -22,7 +23,7 @@ public override async Task> FetchAllItemsAsync() try { - var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}"; + 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); @@ -71,7 +72,7 @@ public override async Task> FetchAllItemsAsync() try { - var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + 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); @@ -123,7 +124,7 @@ public override async Task> FetchAllItemsAsync() return null; } - var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + 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); diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs index bb689cf..00fedfb 100644 --- a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -16,12 +16,8 @@ public class ArrSyncBackgroundService( LidarrClient lidarrClient, ReadarrClient readarrClient, CacheService cacheService, - IConfiguration configuration, ILogger logger) : BackgroundService { - private readonly bool _sonarrEnabled = configuration.GetValue("SONARR_ENABLED"); - private readonly bool _lidarrEnabled = configuration.GetValue("LIDARR_ENABLED"); - private readonly bool _readarrEnabled = configuration.GetValue("READARR_ENABLED"); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("ArrSyncBackgroundService is starting."); @@ -62,17 +58,17 @@ private async Task FetchAndUpdateDataAsync() try { var success = true; - if (_readarrEnabled) + if (readarrClient.ReadarrOptions.Enabled) { var syncSuccess = await FetchItemsFromReadarrAsync(); success = success && syncSuccess; } - if (_sonarrEnabled) + if (sonarrClient.SonarrOptions.Enabled) { var syncSuccess = await FetchItemsFromSonarrAsync(); success = success && syncSuccess; } - if (_lidarrEnabled) + if (lidarrClient.LidarrOptions.Enabled) { var syncSuccess = await FetchItemsFromLidarrAsync(); success = success && syncSuccess; diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs new file mode 100644 index 0000000..074a58a --- /dev/null +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace UmlautAdaptarr.Services +{ + public class HttpProxyService : IHostedService + { + private TcpListener _listener; + private readonly ILogger _logger; + private readonly int _proxyPort = 5006; // TODO move to appsettings.json + private readonly IHttpClientFactory _clientFactory; + private HashSet _knownHosts = []; + private readonly object _hostsLock = new object(); + + + public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory) + { + _logger = logger; + _clientFactory = clientFactory; + _knownHosts.Add("prowlarr.servarr.com"); + } + + private async Task HandleRequests(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var clientSocket = await _listener.AcceptSocketAsync(); + _ = Task.Run(() => ProcessRequest(clientSocket), stoppingToken); + } + } + + private async Task ProcessRequest(Socket clientSocket) + { + using var clientStream = new NetworkStream(clientSocket, ownsSocket: true); + var buffer = new byte[8192]; + var bytesRead = await clientStream.ReadAsync(buffer, 0, buffer.Length); + var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead); + + if (requestString.StartsWith("CONNECT")) + { + // Handle HTTPS CONNECT request + await HandleHttpsConnect(requestString, clientStream, clientSocket); + } + else + { + // Handle HTTP request + await HandleHttp(requestString, clientStream, clientSocket, buffer, bytesRead); + } + } + + private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket) + { + var (host, port) = ParseTargetInfo(requestString); + + // Prowlarr will send grab requests via https which cannot be changed + if (!_knownHosts.Contains(host)) + { + _logger.LogWarning($"IMPORTANT! {Environment.NewLine} Indexer {host} needs to be set to http:// instead of https:// {Environment.NewLine}" + + $"UmlautAdaptarr will not work for {host}!"); + } + using var targetSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + await targetSocket.ConnectAsync(host, port); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection Established\r\n\r\n")); + using var targetStream = new NetworkStream(targetSocket, ownsSocket: true); + await RelayTraffic(clientStream, targetStream); + } + catch (Exception ex) + { + _logger.LogError($"Failed to connect to target: {ex.Message}"); + clientSocket.Close(); + } + } + + private async Task HandleHttp(string requestString, NetworkStream clientStream, Socket clientSocket, byte[] buffer, int bytesRead) + { + try + { + var headers = ParseHeaders(buffer, bytesRead); + string userAgent = headers.FirstOrDefault(h => h.Key == "User-Agent").Value; + var uri = new Uri(requestString.Split(' ')[1]); + + // Add to known hosts if not already present + lock (_hostsLock) + { + if (!_knownHosts.Contains(uri.Host)) + { + _knownHosts.Add(uri.Host); + } + } + + var modifiedUri = $"http://localhost:5005/_/{uri.Host}{uri.PathAndQuery}"; // TODO read port from appsettings? + using var client = _clientFactory.CreateClient(); + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri); + httpRequestMessage.Headers.Add("User-Agent", userAgent); + var result = await client.SendAsync(httpRequestMessage); + + if (result.IsSuccessStatusCode) + { + var responseData = await result.Content.ReadAsByteArrayAsync(); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Length: {responseData.Length}\r\n\r\n")); + await clientStream.WriteAsync(responseData); + } + else + { + await clientStream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {result.StatusCode}\r\n\r\n")); + } + } + catch (Exception ex) + { + _logger.LogError($"HTTP Proxy error: {ex.Message}"); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 500 Internal Server Error\r\n\r\n")); + } + finally + { + clientSocket.Close(); + } + } + + private Dictionary ParseHeaders(byte[] buffer, int length) + { + var headers = new Dictionary(); + var headerString = Encoding.ASCII.GetString(buffer, 0, length); + var lines = headerString.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines.Skip(1)) // Skip the request line + { + var colonIndex = line.IndexOf(':'); + if (colonIndex > 0) + { + var key = line.Substring(0, colonIndex).Trim(); + var value = line.Substring(colonIndex + 1).Trim(); + headers[key] = value; + } + } + return headers; + } + + private (string host, int port) ParseTargetInfo(string requestLine) + { + var parts = requestLine.Split(' ')[1].Split(':'); + return (parts[0], int.Parse(parts[1])); + } + + private async Task RelayTraffic(NetworkStream clientStream, NetworkStream targetStream) + { + var clientToTargetTask = RelayStream(clientStream, targetStream); + var targetToClientTask = RelayStream(targetStream, clientStream); + await Task.WhenAll(clientToTargetTask, targetToClientTask); + } + + private async Task RelayStream(NetworkStream input, NetworkStream output) + { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = await input.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) + { + await output.WriteAsync(buffer.AsMemory(0, bytesRead)); + await output.FlushAsync(); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _listener = new TcpListener(IPAddress.Any, _proxyPort); + _listener.Start(); + Task.Run(() => HandleRequests(cancellationToken), cancellationToken); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _listener.Stop(); + return Task.CompletedTask; + } + } +} diff --git a/UmlautAdaptarr/Services/ProxyService.cs b/UmlautAdaptarr/Services/ProxyRequestService.cs similarity index 84% rename from UmlautAdaptarr/Services/ProxyService.cs rename to UmlautAdaptarr/Services/ProxyRequestService.cs index 6099ed4..0011244 100644 --- a/UmlautAdaptarr/Services/ProxyService.cs +++ b/UmlautAdaptarr/Services/ProxyRequestService.cs @@ -1,22 +1,26 @@ using Microsoft.Extensions.Caching.Memory; using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Services { - public class ProxyService + public class ProxyRequestService { private readonly HttpClient _httpClient; private readonly string _userAgent; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IMemoryCache _cache; + private readonly GlobalOptions _options; private static readonly ConcurrentDictionary _lastRequestTimes = new(); private static readonly TimeSpan MINIMUM_DELAY_FOR_SAME_HOST = new(0, 0, 0, 1); - public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger logger, IMemoryCache cache) + public ProxyRequestService(IHttpClientFactory clientFactory, ILogger logger, IMemoryCache cache, IOptions options) { + _options = options.Value; _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(nameof(clientFactory)); - _userAgent = configuration["Settings:UserAgent"] ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); + _userAgent = _options.UserAgent ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); _logger = logger; _cache = cache; } @@ -72,12 +76,12 @@ public async Task ProxyRequestAsync(HttpContext context, st try { - _logger.LogInformation($"ProxyService GET {UrlUtilities.RedactApiKey(targetUri)}"); + _logger.LogInformation($"ProxyRequestService GET {UrlUtilities.RedactApiKey(targetUri)}"); var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); if (responseMessage.IsSuccessStatusCode) { - _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(5)); + _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(12)); } return responseMessage; @@ -86,7 +90,6 @@ public async Task ProxyRequestAsync(HttpContext context, st { _logger.LogError(ex, $"Error proxying request: {UrlUtilities.RedactApiKey(targetUri)}. Error: {ex.Message}"); - // Create a response message indicating an internal server error var errorResponse = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError) { Content = new StringContent($"An error occurred while processing your request: {ex.Message}") diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index 0cf0ef3..eb68495 100644 --- a/UmlautAdaptarr/Services/SearchItemLookupService.cs +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -6,12 +6,8 @@ namespace UmlautAdaptarr.Services public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient, ReadarrClient readarrClient, - LidarrClient lidarrClient, - IConfiguration configuration) + LidarrClient lidarrClient) { - private readonly bool _sonarrEnabled = configuration.GetValue("SONARR_ENABLED"); - private readonly bool _lidarrEnabled = configuration.GetValue("LIDARR_ENABLED"); - private readonly bool _readarrEnabled = configuration.GetValue("READARR_ENABLED"); public async Task GetOrFetchSearchItemByExternalId(string mediaType, string externalId) { // Attempt to get the item from the cache first @@ -26,20 +22,20 @@ public class SearchItemLookupService(CacheService cacheService, switch (mediaType) { case "tv": - if (_sonarrEnabled) + if (sonarrClient.SonarrOptions.Enabled) { fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); } break; case "audio": - if (_lidarrEnabled) + if (lidarrClient.LidarrOptions.Enabled) { - fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId); + await lidarrClient.FetchItemByExternalIdAsync(externalId); fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); } break; case "book": - if (_readarrEnabled) + if (readarrClient.ReadarrOptions.Enabled) { await readarrClient.FetchItemByExternalIdAsync(externalId); fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); @@ -70,7 +66,7 @@ public class SearchItemLookupService(CacheService cacheService, switch (mediaType) { case "tv": - if (_sonarrEnabled) + if (sonarrClient.SonarrOptions.Enabled) { fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); } diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs index c48988e..26733c2 100644 --- a/UmlautAdaptarr/Services/TitleApiService.cs +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -1,13 +1,15 @@ -using Newtonsoft.Json; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Services { - public class TitleApiService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger logger) + public class TitleApiService(IHttpClientFactory clientFactory, ILogger logger, IOptions options) { - private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] - ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json"); + public GlobalOptions Options { get; } = options.Value; + private DateTime lastRequestTime = DateTime.MinValue; private async Task EnsureMinimumDelayAsync() @@ -28,7 +30,7 @@ private async Task EnsureMinimumDelayAsync() await EnsureMinimumDelayAsync(); var httpClient = clientFactory.CreateClient(); - var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; + var titleApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}"); var response = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponseData = JsonConvert.DeserializeObject(response); @@ -74,7 +76,7 @@ private async Task EnsureMinimumDelayAsync() var httpClient = clientFactory.CreateClient(); var tvdbCleanTitle = title.Replace("ß", "ss"); - var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; + var titleApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}"); var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 1c6c847..d229474 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -228,7 +228,7 @@ private static void FindAndReplaceForMoviesAndTV(ILogger l titleElement.Value = newTitle; logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'"); - break; // Break after the first successful match and modification + break; } } } @@ -242,7 +242,7 @@ private static string ReplaceSeperatorsWithSpace(string title) private static char FindFirstSeparator(string title) { var match = WordSeperationCharRegex().Match(title); - return match.Success ? match.Value.First() : ' '; // Default to space if no separator found + return match.Success ? match.Value.First() : ' '; } private static string ReconstructTitleWithSeparator(string title, char separator) diff --git a/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs b/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs deleted file mode 100644 index eba17d4..0000000 --- a/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Newtonsoft.Json; -using UmlautAdaptarr.Models; -using UmlautAdaptarr.Providers; -using UmlautAdaptarr.Utilities; - -namespace UmlautAdaptarr.Services -{ - public class TitleQueryServiceLegacy( - IMemoryCache memoryCache, - ILogger logger, - IConfiguration configuration, - IHttpClientFactory clientFactory, - SonarrClient sonarrClient) - { - private readonly HttpClient _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(); - private readonly string _sonarrHost = configuration.GetValue("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set"); - private readonly string _sonarrApiKey = configuration.GetValue("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set"); - private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json"); - - /*public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTVDBId(string tvdbId) - { - var sonarrCacheKey = $"SearchItem_Sonarr_{tvdbId}"; - - if (memoryCache.TryGetValue(sonarrCacheKey, out SearchItem? cachedItem)) - { - return (cachedItem?.HasGermanUmlaut ?? false, cachedItem?.GermanTitle, cachedItem?.ExpectedTitle ?? string.Empty); - } - else - { - var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; - 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 (false, null, string.Empty); - } - else if (shows.Count == 0) - { - logger.LogWarning($"No results found for TVDB ID {tvdbId}"); - return (false, null, string.Empty); - } - - var expectedTitle = (string)shows[0].title; - if (expectedTitle == null) - { - logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); - return (false, null, string.Empty); - } - - string? germanTitle = null; - var hasGermanTitle = false; - - var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={tvdbId}"; - var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl); - var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); - - if (titleApiResponseData == null) - { - logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for TVDB ID {tvdbId} resulted in null"); - return (false, null, string.Empty); - } - - if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) - { - germanTitle = titleApiResponseData.germanTitle; - hasGermanTitle = true; - } - - var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; - - var result = (hasGermanUmlaut, germanTitle, expectedTitle); - memoryCache.Set(showCacheKey, result, new MemoryCacheEntryOptions - { - Size = 1, - SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) - }); - - return result; - } - }*/ - - // This method is being used if the *arrs do a search with the "q" parameter (text search) - public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTitle(string title) - { - // TVDB doesn't use ß - TODO: Determine if this is true - var tvdbCleanTitle = title.Replace("ß", "ss"); - - var cacheKey = $"show_{tvdbCleanTitle}"; - if (memoryCache.TryGetValue(cacheKey, out (bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle) cachedResult)) - { - return cachedResult; - } - - var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; - var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl); - var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); - - if (titleApiResponseData == null) - { - logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for title {title} resulted in null"); - return (false, null, string.Empty); - } - - if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) - { - var tvdbId = (string)titleApiResponseData.tvdbId; - if (tvdbId == null) - { - logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response tvdbId {titleApiResponseData} resulted in null"); - return (false, null, string.Empty); - } - - var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; - 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 (false, null, string.Empty); - } - else if (shows.Count == 0) - { - logger.LogWarning($"No results found for TVDB ID {tvdbId}"); - return (false, null, string.Empty); - } - - var expectedTitle = (string)shows[0].title; - if (expectedTitle == null) - { - logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); - return (false, null, string.Empty); - } - - string germanTitle ; - bool hasGermanTitle; - - germanTitle = titleApiResponseData.germanTitle; - hasGermanTitle = true; - - var hasGermanUmlaut = germanTitle?.HasUmlauts() ?? false; - - var result = (hasGermanUmlaut, germanTitle, expectedTitle); - memoryCache.Set(cacheKey, result, new MemoryCacheEntryOptions - { - Size = 1, - SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) - }); - - return result; - } - else - { - logger.LogWarning($"UmlautAdaptarr TitleQuery {titleApiUrl} didn't succeed."); - return (false, null, string.Empty); - } - } - } -} diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 5b1fa31..23722e0 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,6 +9,8 @@ + + diff --git a/UmlautAdaptarr/Utilities/GlobalStaticLogger.cs b/UmlautAdaptarr/Utilities/GlobalStaticLogger.cs new file mode 100644 index 0000000..e7fdbbe --- /dev/null +++ b/UmlautAdaptarr/Utilities/GlobalStaticLogger.cs @@ -0,0 +1,19 @@ +namespace UmlautAdaptarr.Utilities +{ + /// + /// Service for providing a static logger to log errors and information. + /// The GlobalStaticLogger is designed to provide a static logger that can be used to log errors and information. + /// It facilitates logging for both static classes and extension methods. + /// + public static class GlobalStaticLogger + { + + public static ILogger Logger; + + /// + /// Initializes the GlobalStaticLogger with the provided logger factory. + /// + /// The ILoggerFactory instance used to create loggers. + public static void Initialize(ILoggerFactory loggerFactory) => Logger = loggerFactory.CreateLogger("GlobalStaticLogger"); + } +} diff --git a/UmlautAdaptarr/Utilities/ProxyExtension.cs b/UmlautAdaptarr/Utilities/ProxyExtension.cs new file mode 100644 index 0000000..6a448ba --- /dev/null +++ b/UmlautAdaptarr/Utilities/ProxyExtension.cs @@ -0,0 +1,53 @@ +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/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs new file mode 100644 index 0000000..2cc188d --- /dev/null +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -0,0 +1,92 @@ +using UmlautAdaptarr.Options; +using UmlautAdaptarr.Options.ArrOptions; +using UmlautAdaptarr.Providers; +using UmlautAdaptarr.Services; + +namespace UmlautAdaptarr.Utilities +{ + /// + /// Extension methods for configuring services related to ARR Applications + /// + public static class ServicesExtensions + { + + /// + /// 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.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"); + } + } +} diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 7255729..56b0dde 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -3,18 +3,64 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "TimestampFormat": "yyyy-MM-dd HH:mm:ss::" } }, "AllowedHosts": "*", "Kestrel": { "Endpoints": { "Http": { - "Url": "http://*:5005" + "Url": "http://[::]:5005" } } }, + // Settings__UserAgent=UmlautAdaptarr/1.0 + // Settings__UmlautAdaptarrApiHost=https://umlautadaptarr.pcjones.de/api/v1 "Settings": { "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 + "Enabled": false, + "Host": "your_lidarr_host_url", + "ApiKey": "your_lidarr_api_key" + }, + "Readarr": { + // Docker Environment Variables: + // - Readarr__Enabled: true (set to false to disable) + // - Readarr__Host: your_readarr_host_url + // - Readarr__ApiKey: your_readarr_api_key + "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 } } diff --git a/UmlautAdaptarr/secrets_example.json b/UmlautAdaptarr/secrets_example.json deleted file mode 100644 index 8f58df8..0000000 --- a/UmlautAdaptarr/secrets_example.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "SONARR_ENABLED": false, - "SONARR_HOST": "http://localhost:8989", - "SONARR_API_KEY": "", - "LIDARR_ENABLED": false, - "LIDARR_HOST": "http://localhost:8686", - "LIDARR_API_KEY": "", - "READARR_ENABLED": false, - "READARR_HOST": "http://localhost:8787", - "READARR_API_KEY": "" -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2cc732f..71f32d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,17 +8,20 @@ services: restart: unless-stopped environment: - TZ=Europe/Berlin - - SONARR_ENABLED=false - - SONARR_HOST=http://localhost:8989 - - SONARR_API_KEY=API_KEY - - RADARR_ENABLED=false - - RADARR_HOST=http://localhost:7878 - - RADARR_API_KEY=API_KEY - - READARR_ENABLED=false - - READARR_HOST=http://localhost:8787 - - READARR_API_KEY=API_KEY - - LIDARR_ENABLED=false - - LIDARR_HOST=http://localhost:8686 - - LIDARR_API_KEY=API_KEY - ports: - - "5005":"5005" + - SONARR__ENABLED=false + - SONARR__HOST=http://localhost:8989 + - SONARR__APIKEY=APIKEY + - RADARR__ENABLED=false + - RADARR__HOST=http://localhost:7878 + - RADARR__APIKEY=APIKEY + - READARR__ENABLED=false + - READARR__HOST=http://localhost:8787 + - READARR__APIKEY=APIKEY + - 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) \ No newline at end of file