diff --git a/README.md b/README.md index eced4dc..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,19 +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 | ✓ | -| 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 |✓| -| Radarr Support | 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? | @@ -46,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) @@ -61,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: @@ -130,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) 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/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/Models/IpInfo.cs b/UmlautAdaptarr/Models/IpInfo.cs new file mode 100644 index 0000000..6abaf27 --- /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 diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs index 29fdabf..83e2d55 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) { @@ -152,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/Options/ArrOptions/ArrApplicationBaseOptions.cs b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs similarity index 65% rename from UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs rename to UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs index dc7df56..2d3e989 100644 --- a/UmlautAdaptarr/Options/ArrOptions/ArrApplicationBaseOptions.cs +++ b/UmlautAdaptarr/Options/ArrOptions/InstanceOptions/GlobalInstanceOptions.cs @@ -1,15 +1,17 @@ -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. /// public bool Enabled { get; set; } + /// + /// Name of the Instance + /// + public string Name { get; set; } + /// /// The host of the ARR application. /// @@ -20,4 +22,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/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 b1b4eae..c6e42f4 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,31 +1,34 @@ using System.Net; -using UmlautAdaptarr.Options; +using Serilog; +using Serilog.Filters; using UmlautAdaptarr.Routing; using UmlautAdaptarr.Services; +using UmlautAdaptarr.Services.Factory; using UmlautAdaptarr.Utilities; internal class Program { private static void Main(string[] args) { + Helper.ShowLogo(); + Helper.ShowInformation(); // TODO: // add option to sort by nzb age - - - var builder = WebApplication.CreateBuilder(args); - + var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; + ConfigureLogger(configuration); + + 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(); - handler.ConfigureProxy(proxyOptions); return handler; }); @@ -35,20 +38,8 @@ internal class Program //options.SizeLimit = 20000; }); - - // 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.AllowResolvingKeyedServicesAsDictionary(); builder.Services.AddControllers(); - builder.Services.AddHostedService(); builder.AddTitleLookupService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -57,6 +48,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 +58,55 @@ 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(); } + + 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 diff --git a/UmlautAdaptarr/Providers/ArrClientBase.cs b/UmlautAdaptarr/Providers/ArrClientBase.cs index 80a89ce..8d2b9b8 100644 --- a/UmlautAdaptarr/Providers/ArrClientBase.cs +++ b/UmlautAdaptarr/Providers/ArrClientBase.cs @@ -1,13 +1,14 @@ -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); - } -} +#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); +} \ 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..55a7493 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 ({InstanceName}) : {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..61d4894 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( + ArrApplicationFactory arrApplicationFactory, + CacheService cacheService, + ILogger logger) + : BackgroundService { - public class ArrSyncBackgroundService( - SonarrClient sonarrClient, - LidarrClient lidarrClient, - ReadarrClient readarrClient, - CacheService cacheService, - ILogger logger) : BackgroundService + public ArrApplicationFactory ArrApplicationFactory { get; } = arrApplicationFactory; + + + 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 (ArrApplicationFactory.SonarrInstances.Any()) + { + var syncSuccess = await FetchItemsFromSonarrAsync(); + success = success && syncSuccess; + } + + if (ArrApplicationFactory.ReadarrInstances.Any()) + { + var syncSuccess = await FetchItemsFromReadarrAsync(); + success = success && syncSuccess; + } + + if (ArrApplicationFactory.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 ArrApplicationFactory.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 ArrApplicationFactory.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 ArrApplicationFactory.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/ArrApplicationFactory.cs b/UmlautAdaptarr/Services/Factory/ArrApplicationFactory.cs new file mode 100644 index 0000000..2fe4ddc --- /dev/null +++ b/UmlautAdaptarr/Services/Factory/ArrApplicationFactory.cs @@ -0,0 +1,77 @@ +using UmlautAdaptarr.Interfaces; +using UmlautAdaptarr.Providers; + +namespace UmlautAdaptarr.Services.Factory +{ + /// + /// Factory for creating RrApplication instances. + /// + public class ArrApplicationFactory + { + 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 ArrApplicationFactory. + /// + /// A dictionary of IArrApplication instances. + /// Logger Instanz + public ArrApplicationFactory(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("Error while Register ArrFactory. This might be a Config Problem", 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/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 074a58a..2017db4 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -10,13 +10,15 @@ 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 object _hostsLock = new object(); + 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"); } @@ -34,7 +36,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")) @@ -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); @@ -123,21 +128,21 @@ 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(':'); 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; } } 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 +155,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/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index eb68495..606133a 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) + ArrApplicationFactory arrApplicationFactory) { 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 = arrApplicationFactory.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 = arrApplicationFactory.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 = arrApplicationFactory.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 = arrApplicationFactory.SonarrInstances; + foreach (var sonarrClient in sonarrInstances) { fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); } diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index d229474..802c92b 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; @@ -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}'"); @@ -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 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); + 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 @@ -278,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"; } diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 23722e0..1969466 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,10 +9,13 @@ + + - + + 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/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+")] diff --git a/UmlautAdaptarr/Utilities/Helper.cs b/UmlautAdaptarr/Utilities/Helper.cs new file mode 100644 index 0000000..002310e --- /dev/null +++ b/UmlautAdaptarr/Utilities/Helper.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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"); + } + + public static void ShowInformation() + { + Console.WriteLine("--------------------------[IP Leak Test]-----------------------------"); + var ipInfo = GetPublicIpAddressInfoAsync().GetAwaiter().GetResult(); + + if (ipInfo != null) + { + 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; + } + } +} 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/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/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 2cc188d..7037172 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -1,92 +1,191 @@ -using UmlautAdaptarr.Options; -using UmlautAdaptarr.Options.ArrOptions; +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 +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 + /// Logger instance for logging proxy configurations. /// - public static class ServicesExtensions + private static ILogger Logger = GlobalStaticLogger.Logger; + + /// + /// 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 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 { - - /// - /// 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 + try { - if (builder.Services == null) + 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 an 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) { - throw new ArgumentNullException(nameof(builder), "Service collection is null."); + GlobalInstanceOptionsValidator validator = new GlobalInstanceOptionsValidator(); + + var results = validator.Validate(option as GlobalInstanceOptions); + + if (!results.IsValid) + { + foreach (var failure in results.Errors) + { + Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}")); + } + + throw new Exception("Please fix first you config and then Start UmlautAdaptarr again"); + } + + 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(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); + } } - 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) + catch (Exception ex) { - return builder.AddServiceWithOptions("Sonarr"); + Console.WriteLine($"Error in AddServicesWithOptions: {ex.Message}"); + throw; } - /// - /// 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"); - } } -} + + /// + /// 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() ?? 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) + { + // builder.Serviceses.AddSingleton, OptionsMonitoSonarrInstanceOptionsns>>(); + 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/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs new file mode 100644 index 0000000..eca7e94 --- /dev/null +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -0,0 +1,82 @@ +using System.Net; +using FluentValidation; +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/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; + + while (DateTime.Now < endTime) + { + try + { + // TODO use HttpClient here + var request = (HttpWebRequest)WebRequest.Create(url); + request.AllowAutoRedirect = false; + request.Timeout = 3000; + using var response = (HttpWebResponse)request.GetResponse(); + reachable = response.StatusCode == HttpStatusCode.OK; + if (reachable) + { + break; + } + // If status is 301/302 (Found), follow the redirect manually + else if (response.StatusCode == HttpStatusCode.MovedPermanently || response.StatusCode == HttpStatusCode.Found) + { + var redirectUrl = response.Headers["Location"]; // Get the redirect URL + if (!string.IsNullOrEmpty(redirectUrl)) + { + // Create a new request for the redirected URL + var redirectRequest = (HttpWebRequest)WebRequest.Create(redirectUrl); + redirectRequest.Timeout = 3000; + using var redirectResponse = (HttpWebResponse)redirectRequest.GetResponse(); + reachable = redirectResponse.StatusCode == HttpStatusCode.OK; + if (reachable) + { + break; + } + } + } + } + catch + { + + } + + // Wait for 15 seconds for next try + Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds..."); + Thread.Sleep(15000); + } + + return reachable; + } + +} \ No newline at end of file diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 56b0dde..b9b4ba0 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -22,20 +22,36 @@ "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__Name: Name of the Instance (Optional) + // - 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" + }, + { + // Docker Environment Variables: + // - Sonarr__1__Enabled: true (set to false to disable) + // - Sonarr__1__Name: Name of the Instance (Optional) + // - 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" + } + ], + "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" @@ -48,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 } } 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 diff --git a/docker-compose.yml b/docker-compose.yml index 3a12ed0..7c97b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,8 +23,12 @@ 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) + ### 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