diff --git a/UmlautAdaptarr.sln b/UmlautAdaptarr.sln new file mode 100644 index 0000000..040e23b --- /dev/null +++ b/UmlautAdaptarr.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34408.163 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UmlautAdaptarr", "UmlautAdaptarr\UmlautAdaptarr.csproj", "{5561BAF7-C3DD-4E27-B8DA-25559CB098B6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5561BAF7-C3DD-4E27-B8DA-25559CB098B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5561BAF7-C3DD-4E27-B8DA-25559CB098B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5561BAF7-C3DD-4E27-B8DA-25559CB098B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5561BAF7-C3DD-4E27-B8DA-25559CB098B6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7BE3DEF8-5290-4252-A74D-1E3BEAF58990} + EndGlobalSection +EndGlobal diff --git a/UmlautAdaptarr/Controllers/CapsController.cs b/UmlautAdaptarr/Controllers/CapsController.cs new file mode 100644 index 0000000..0d46837 --- /dev/null +++ b/UmlautAdaptarr/Controllers/CapsController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Xml.Linq; +using UmlautAdaptarr.Services; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Controllers +{ + public class CapsController(ProxyService proxyService) : ControllerBase + { + private readonly ProxyService _proxyService = proxyService; + + [HttpGet] + public async Task Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) + { + if (!UrlUtilities.IsValidDomain(domain)) + { + return NotFound($"{domain} is not a valid URL."); + } + + var requestUrl = UrlUtilities.BuildUrl(domain, "caps", apikey); + + var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); + + var content = await responseMessage.Content.ReadAsStringAsync(); + var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? + Encoding.GetEncoding(responseMessage.Content.Headers.ContentType.CharSet) : + Encoding.UTF8; + var contentType = responseMessage.Content.Headers.ContentType?.MediaType ?? "application/xml"; + + return Content(content, contentType, encoding); + } + } +} diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs new file mode 100644 index 0000000..e592e70 --- /dev/null +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text; +using UmlautAdaptarr.Services; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Controllers +{ + public abstract class SearchControllerBase(ProxyService proxyService) : ControllerBase + { + protected readonly ProxyService _proxyService = proxyService; + + protected async Task BaseSearch(string options, string domain, IDictionary queryParameters) + { + if (!UrlUtilities.IsValidDomain(domain)) + { + return NotFound($"{domain} is not a valid URL."); + } + + var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); + + var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); + + var content = await responseMessage.Content.ReadAsStringAsync(); + var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? + Encoding.GetEncoding(responseMessage.Content.Headers.ContentType.CharSet) : + Encoding.UTF8; + var contentType = responseMessage.Content.Headers.ContentType?.MediaType ?? "application/xml"; + + return Content(content, contentType, encoding); + } + } + + public class SearchController(ProxyService proxyService, TitleQueryService titleQueryService) : SearchControllerBase(proxyService) + { + [HttpGet] + public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) + { + var queryParameters = HttpContext.Request.Query.ToDictionary( + q => q.Key, + q => string.Join(",", q.Value)); + return await BaseSearch(options, domain, queryParameters); + } + + [HttpGet] + public async Task GenericSearch([FromRoute] string options, [FromRoute] string domain) + { + var queryParameters = HttpContext.Request.Query.ToDictionary( + q => q.Key, + q => string.Join(",", q.Value)); + return await BaseSearch(options, domain, queryParameters); + } + + [HttpGet] + public async Task BookSearch([FromRoute] string options, [FromRoute] string domain) + { + var queryParameters = HttpContext.Request.Query.ToDictionary( + q => q.Key, + q => string.Join(",", q.Value)); + return await BaseSearch(options, domain, queryParameters); + } + + [HttpGet] + public async Task TVSearch([FromRoute] string options, [FromRoute] string domain) + { + var queryParameters = HttpContext.Request.Query.ToDictionary( + q => q.Key, + q => string.Join(",", q.Value)); + + + if (queryParameters.TryGetValue("tvdbid", out string tvdbId)) + { + var (HasGermanUmlaut, GermanTitle, ExpectedTitle) = await titleQueryService.QueryShow(tvdbId); + + if (GermanTitle == null && ExpectedTitle == null) + { + return NotFound($"Show with TVDB ID {tvdbId} not found."); + } + + } + + return await BaseSearch(options, domain, queryParameters); + } + + [HttpGet] + public async Task MusicSearch([FromRoute] string options, [FromRoute] string domain) + { + var queryParameters = HttpContext.Request.Query.ToDictionary( + q => q.Key, + q => string.Join(",", q.Value)); + return await BaseSearch(options, domain, queryParameters); + } + } +} diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs new file mode 100644 index 0000000..ca52875 --- /dev/null +++ b/UmlautAdaptarr/Program.cs @@ -0,0 +1,70 @@ +using System.Net; +using UmlautAdaptarr.Routing; +using UmlautAdaptarr.Services; + +internal class Program +{ + private static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() => + { + var handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli + }; + + return handler; + }); + + builder.Services.AddMemoryCache(options => + { + options.SizeLimit = 500; + }); + builder.Services.AddSingleton>(); + + builder.Services.AddControllers(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + + 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(name: "movie-search", + pattern: "{options}/{*domain}", + defaults: new { controller = "Search", action = "MovieSearch" }, + constraints: 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(name: "music-search", + pattern: "{options}/{*domain}", + defaults: new { controller = "Search", action = "MusicSearch" }, + constraints: 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.Run(); + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json new file mode 100644 index 0000000..054c6b0 --- /dev/null +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5182" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7068;http://localhost:5182" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:49485", + "sslPort": 44380 + } + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Routing/TRouteConstraint.cs b/UmlautAdaptarr/Routing/TRouteConstraint.cs new file mode 100644 index 0000000..3df728c --- /dev/null +++ b/UmlautAdaptarr/Routing/TRouteConstraint.cs @@ -0,0 +1,23 @@ +namespace UmlautAdaptarr.Routing +{ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + + public class TRouteConstraint : IRouteConstraint + { + private readonly string _methodName; + + public TRouteConstraint(string methodName) + { + _methodName = methodName; + } + + public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + var t = httpContext.Request.Query["t"].ToString(); + + return t == _methodName; + } + } + +} diff --git a/UmlautAdaptarr/Services/ProxyService.cs b/UmlautAdaptarr/Services/ProxyService.cs new file mode 100644 index 0000000..3fe6606 --- /dev/null +++ b/UmlautAdaptarr/Services/ProxyService.cs @@ -0,0 +1,59 @@ +namespace UmlautAdaptarr.Services +{ + public class ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration) + { + private readonly HttpClient _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(); + private readonly string _userAgent = configuration["Settings:UserAgent"] ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); + + public async Task ProxyRequestAsync(HttpContext context, string targetUri) + { + var requestMessage = new HttpRequestMessage(); + var requestMethod = context.Request.Method; + + if (!HttpMethods.IsGet(requestMethod)) + { + throw new ArgumentException("Only GET requests are supported", nameof(requestMethod)); + } + + // Copy the request headers + foreach (var header in context.Request.Headers) + { + if (header.Key == "User-Agent" && _userAgent.Length != 0) + { + requestMessage.Headers.TryAddWithoutValidation(header.Key, $"{header.Value} + {_userAgent}"); + } + else if (!header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + + requestMessage.RequestUri = new Uri(targetUri); + requestMessage.Method = HttpMethod.Get; + + //var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + try + { + var responseMessage = _httpClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + responseMessage.EnsureSuccessStatusCode(); + + // Modify the response content if necessary + /*var content = await responseMessage.Content.ReadAsStringAsync(); + content = ReplaceCharacters(content); + responseMessage.Content = new StringContent(content);*/ + + return responseMessage; + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + return null; + } + } + + private string ReplaceCharacters(string input) + { + return input.Replace("Ä", "AE"); + } + } +} diff --git a/UmlautAdaptarr/Services/TitleQueryService.cs b/UmlautAdaptarr/Services/TitleQueryService.cs new file mode 100644 index 0000000..f1101ef --- /dev/null +++ b/UmlautAdaptarr/Services/TitleQueryService.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Services +{ + public class TitleQueryService + { + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly string _sonarrHost; + private readonly string _sonarrApiKey; + + public TitleQueryService(HttpClient httpClient, IMemoryCache memoryCache, ILogger logger) + { + _httpClient = httpClient; + _cache = memoryCache; + _logger = logger; + + _sonarrHost = Environment.GetEnvironmentVariable("SONARR_HOST"); + _sonarrApiKey = Environment.GetEnvironmentVariable("SONARR_API_KEY"); + } + + public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryShow(string tvdbId) + { + var cacheKey = $"show_{tvdbId}"; + if (_cache.TryGetValue(cacheKey, out (bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle) cachedResult)) + { + return cachedResult; + } + + var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + var response = await _httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(response); + + if (shows == null) + { + _logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); + return (false, null, string.Empty); + } else if (shows.Count == 0) + { + _logger.LogWarning($"No results found for TVDB ID {tvdbId}"); + return (false, null, string.Empty); + } + + var expectedTitle = shows[0].title as string; + if (expectedTitle == null) + { + _logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); + return (false, null, string.Empty); + } + + string? germanTitle = null; + var hasGermanTitle = false; + + if ((string)shows[0].originalLanguage.name != "German") + { + var thetvdbUrl = $"https://umlautadaptarr.pcjones.de/get_german_title.php?tvdbid={tvdbId}"; + var tvdbResponse = await _httpClient.GetStringAsync(thetvdbUrl); + var tvdbData = JsonConvert.DeserializeObject(tvdbResponse); + + if (tvdbData == null) + { + _logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for TVDB ID {tvdbId} resulted in null"); + return (false, null, string.Empty); + } + + if (tvdbData.status == "success") + { + germanTitle = tvdbData.germanTitle; + hasGermanTitle = true; + } + } + else + { + germanTitle = expectedTitle; + hasGermanTitle = true; + } + + var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; + + var result = (hasGermanUmlaut, germanTitle, expectedTitle); + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) + }); + + return result; + } + + } +} diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj new file mode 100644 index 0000000..c81b7ff --- /dev/null +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + diff --git a/UmlautAdaptarr/Utilities/Extensions.cs b/UmlautAdaptarr/Utilities/Extensions.cs new file mode 100644 index 0000000..31e5f85 --- /dev/null +++ b/UmlautAdaptarr/Utilities/Extensions.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Text; + +namespace UmlautAdaptarr.Utilities +{ + public static class Extensions + { + public static string GetQuery(this HttpContext context, string key) + { + return context.Request.Query[key].FirstOrDefault() ?? string.Empty; + } + + public static string RemoveAccentButKeepGermanUmlauts(this string text) + { + // TODO: evaluate if this is needed (here) + var stringWithoutSz = text.Replace("ß", "ss"); + + var normalizedString = stringWithoutSz.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + + if (unicodeCategory != UnicodeCategory.NonSpacingMark || c == '\u0308') + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } + + public static string ReplaceGermanUmlautsWithLatinEquivalents(this string text) + { + return text + .Replace("Ö", "Oe") + .Replace("Ä", "Ae") + .Replace("Ü", "Ue") + .Replace("ö", "oe") + .Replace("ä", "ae") + .Replace("ü", "ue") + .Replace("ß", "ss"); + } + + public static bool HasGermanUmlauts(this string text) + { + if (text == null) return false; + var umlauts = new[] { 'ö', 'ä', 'ü', 'Ä', 'Ü', 'Ö', 'ß' }; + return umlauts.Any(text.Contains); + } + } +} diff --git a/UmlautAdaptarr/Utilities/UrlUtilities.cs b/UmlautAdaptarr/Utilities/UrlUtilities.cs new file mode 100644 index 0000000..f90cfe3 --- /dev/null +++ b/UmlautAdaptarr/Utilities/UrlUtilities.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using System.Web; + +namespace UmlautAdaptarr.Utilities +{ + public partial class UrlUtilities + { + [GeneratedRegex(@"^(?!http:\/\/)([a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+.*)$")] + private static partial Regex UrlMatchingRegex(); + public static bool IsValidDomain(string domain) + { + // RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich + // Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle + var regex = UrlMatchingRegex(); + return regex.IsMatch(domain) && !domain.EndsWith("/"); + } + + public static string BuildUrl(string domain, IDictionary queryParameters) + { + var uriBuilder = new UriBuilder("https", domain); + + var query = HttpUtility.ParseQueryString(string.Empty); + foreach (var param in queryParameters) + { + query[param.Key] = param.Value; + } + + uriBuilder.Query = query.ToString(); + return uriBuilder.ToString(); + } + + public static string BuildUrl(string domain, string tParameter, string? apiKey = null) + { + var queryParameters = new Dictionary() { { "t", tParameter } }; + + if (!string.IsNullOrEmpty(apiKey)) + { + queryParameters["apiKey"] = apiKey; + } + + return BuildUrl(domain, queryParameters); + } + } +} diff --git a/UmlautAdaptarr/appsettings.Development.json b/UmlautAdaptarr/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/UmlautAdaptarr/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json new file mode 100644 index 0000000..7783dbc --- /dev/null +++ b/UmlautAdaptarr/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5005" + } + } + }, + "Settings": { + "UserAgent": "UmlautAdaptarr/1.0", + "TitleQueryHost": "https://umlautadaptarr.pcjones.de" + } +}