Projektdateien hinzufügen.

This commit is contained in:
pcjones
2024-02-06 23:28:29 +01:00
parent 3e31f9468a
commit b11cbce725
13 changed files with 586 additions and 0 deletions

View File

@@ -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<IActionResult> 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);
}
}
}

View File

@@ -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<IActionResult> BaseSearch(string options, string domain, IDictionary<string, string> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}
}

70
UmlautAdaptarr/Program.cs Normal file
View File

@@ -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<ILogger, Logger<TitleQueryService>>();
builder.Services.AddControllers();
builder.Services.AddScoped<TitleQueryService>();
builder.Services.AddScoped<ProxyService>();
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();
}
}

View File

@@ -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
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<HttpResponseMessage> 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");
}
}
}

View File

@@ -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<TitleQueryService> _logger;
private readonly string _sonarrHost;
private readonly string _sonarrApiKey;
public TitleQueryService(HttpClient httpClient, IMemoryCache memoryCache, ILogger<TitleQueryService> 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<dynamic>(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<dynamic>(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;
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}
}

View File

@@ -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<string, string> 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<string, string>() { { "t", tParameter } };
if (!string.IsNullOrEmpty(apiKey))
{
queryParameters["apiKey"] = apiKey;
}
return BuildUrl(domain, queryParameters);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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"
}
}