diff --git a/UmlautAdaptarr/Controllers/CapsController.cs b/UmlautAdaptarr/Controllers/CapsController.cs index e8df172..1980670 100644 --- a/UmlautAdaptarr/Controllers/CapsController.cs +++ b/UmlautAdaptarr/Controllers/CapsController.cs @@ -1,18 +1,29 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using System.Xml.Linq; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase + public class CapsController(ProxyRequestService proxyRequestService, IOptions options, ILogger logger) : ControllerBase { private readonly ProxyRequestService _proxyRequestService = proxyRequestService; + private readonly GlobalOptions _options = options.Value; + private readonly ILogger _logger = logger; + [HttpGet] - public async Task Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) + public async Task Caps([FromRoute] string apiKey, [FromRoute] string domain, [FromQuery] string? apikey) { + if (_options.ApiKey != null && !apiKey.Equals(apiKey)) + { + _logger.LogWarning("Invalid or missing API key for request."); + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!domain.StartsWith("localhost") && !UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 9961616..637a64c 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -1,24 +1,31 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using UmlautAdaptarr.Models; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, ILogger logger) : ControllerBase + public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, IOptions options, ILogger logger) : ControllerBase { // TODO evaluate if this should be set to true by default private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; - protected async Task BaseSearch(string options, + protected async Task BaseSearch(string apiKey, string domain, IDictionary queryParameters, SearchItem? searchItem = null) { try { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); @@ -159,30 +166,50 @@ namespace UmlautAdaptarr.Controllers return aggregatedResult; } + + internal bool AssureApiKey(string apiKey) + { + if (options.Value.ApiKey != null && !apiKey.Equals(options.Value.ApiKey)) + { + logger.LogWarning("Invalid or missing API key for request."); + return false; + } + return true; + } } public class SearchController(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, SearchItemLookupService searchItemLookupService, - ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, logger) + IOptions options, + ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, options, logger) { public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"]; [HttpGet] - public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MovieSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task GenericSearch([FromRoute] string options, [FromRoute] string domain) + public async Task GenericSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } - var queryParameters = HttpContext.Request.Query.ToDictionary( + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -208,21 +235,31 @@ namespace UmlautAdaptarr.Controllers } } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task BookSearch([FromRoute] string options, [FromRoute] string domain) + public async Task BookSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task TVSearch([FromRoute] string options, [FromRoute] string domain) + public async Task TVSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -239,16 +276,21 @@ namespace UmlautAdaptarr.Controllers searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title); } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task MusicSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MusicSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } } } diff --git a/UmlautAdaptarr/Options/GlobalOptions.cs b/UmlautAdaptarr/Options/GlobalOptions.cs index 51452a5..ee731cc 100644 --- a/UmlautAdaptarr/Options/GlobalOptions.cs +++ b/UmlautAdaptarr/Options/GlobalOptions.cs @@ -14,5 +14,18 @@ /// The User-Agent string used in HTTP requests. /// public string UserAgent { get; set; } + + /// + /// The duration in minutes to cache the indexer requests. + /// + public int IndexerRequestsCacheDurationInMinutes { get; set; } = 12; + + /// + /// API key for requests to the UmlautAdaptarr. Optional. + public string? ApiKey { get; set; } = null; + + /// + /// Proxy port for the internal UmlautAdaptarr proxy. + public int ProxyPort { get; set; } = 5006; } } \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 5df6506..dc871a0 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -8,7 +8,8 @@ using UmlautAdaptarr.Utilities; internal class Program { - private static void Main(string[] args) { + private static void Main(string[] args) + { MainAsync(args).Wait(); } @@ -68,32 +69,32 @@ internal class Program app.UseAuthorization(); app.MapControllerRoute("caps", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Caps", action = "Caps" }, new { t = new TRouteConstraint("caps") }); app.MapControllerRoute("movie-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MovieSearch" }, new { t = new TRouteConstraint("movie") }); app.MapControllerRoute("tv-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "TVSearch" }, new { t = new TRouteConstraint("tvsearch") }); app.MapControllerRoute("music-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MusicSearch" }, new { t = new TRouteConstraint("music") }); app.MapControllerRoute("book-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "BookSearch" }, new { t = new TRouteConstraint("book") }); app.MapControllerRoute("generic-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "GenericSearch" }, new { t = new TRouteConstraint("search") }); app.Run(); diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json index 37160f1..9677901 100644 --- a/UmlautAdaptarr/Properties/launchSettings.json +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -3,7 +3,8 @@ "http": { "commandName": "Project", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "SETTINGS__ApiKey": "test123" }, "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", "dotnetRunMessages": true, diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index 9d9b0ad..af45b38 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -48,6 +48,21 @@ public class SonarrClient : ArrClientBase if (shows != null) { _logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName})."); + // Bulk request (germanTitle, aliases) for all shows + var tvdbIds = new List(); + foreach (var show in shows) + { + if ((string)show.tvdbId is not string tvdbId) + { + continue; + } + tvdbIds.Add(tvdbId); + } + + var bulkTitleData = await _titleService.FetchGermanTitlesAndAliasesByExternalIdBulkAsync(tvdbIds); + string? germanTitle; + string[]? aliases; + foreach (var show in shows) { var tvdbId = (string)show.tvdbId; @@ -57,8 +72,16 @@ public class SonarrClient : ArrClientBase continue; } - var (germanTitle, aliases) = - await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + if (bulkTitleData.TryGetValue(tvdbId, out var titleData)) + { + (germanTitle, aliases) = titleData; + } + else + { + (germanTitle, aliases) = + await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + } + var searchItem = new SearchItem ( (int)show.id, diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 2017db4..7253905 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -1,6 +1,8 @@ -using System.Net; +using Microsoft.Extensions.Options; +using System.Net; using System.Net.Sockets; using System.Text; +using UmlautAdaptarr.Options; namespace UmlautAdaptarr.Services { @@ -8,15 +10,16 @@ namespace UmlautAdaptarr.Services { private TcpListener _listener; private readonly ILogger _logger; - private readonly int _proxyPort = 5006; // TODO move to appsettings.json private readonly IHttpClientFactory _clientFactory; + private readonly GlobalOptions _options; 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, IConfiguration configuration) + public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory, IConfiguration configuration, IOptions options) { + _options = options.Value; _logger = logger; _configuration = configuration; _clientFactory = clientFactory; @@ -39,6 +42,19 @@ namespace UmlautAdaptarr.Services var bytesRead = await clientStream.ReadAsync(buffer); var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead); + if (_options.ApiKey != null) + { + var headers = ParseHeaders(buffer, bytesRead); + if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) || + !ValidateApiKey(proxyAuthorizationHeader)) + { + _logger.LogWarning("Unauthorized access attempt."); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n")); + clientSocket.Close(); + return; + } + } + if (requestString.StartsWith("CONNECT")) { // Handle HTTPS CONNECT request @@ -50,6 +66,18 @@ namespace UmlautAdaptarr.Services await HandleHttp(requestString, clientStream, clientSocket, buffer, bytesRead); } } + private bool ValidateApiKey(string proxyAuthorizationHeader) + { + // Expect the header to be in the format: "Basic " + if (proxyAuthorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + var encodedKey = proxyAuthorizationHeader["Basic ".Length..].Trim(); + var decodedKey = Encoding.ASCII.GetString(Convert.FromBase64String(encodedKey)); + var password = decodedKey.Split(':')[^1]; + return password == _options.ApiKey; + } + return false; + } private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket) { @@ -96,7 +124,9 @@ namespace UmlautAdaptarr.Services var url = _configuration["Kestrel:Endpoints:Http:Url"]; var port = new Uri(url).Port; - var modifiedUri = $"http://localhost:{port}/_/{uri.Host}{uri.PathAndQuery}"; + var apiKey = _options.ApiKey == null ? "_" : _options.ApiKey; + + var modifiedUri = $"http://localhost:{port}/{apiKey}/{uri.Host}{uri.PathAndQuery}"; using var client = _clientFactory.CreateClient(); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri); httpRequestMessage.Headers.Add("User-Agent", userAgent); @@ -168,7 +198,7 @@ namespace UmlautAdaptarr.Services public Task StartAsync(CancellationToken cancellationToken) { - _listener = new TcpListener(IPAddress.Any, _proxyPort); + _listener = new TcpListener(IPAddress.Any, _options.ProxyPort); _listener.Start(); Task.Run(() => HandleRequests(cancellationToken), cancellationToken); return Task.CompletedTask; diff --git a/UmlautAdaptarr/Services/ProxyRequestService.cs b/UmlautAdaptarr/Services/ProxyRequestService.cs index 0011244..f136305 100644 --- a/UmlautAdaptarr/Services/ProxyRequestService.cs +++ b/UmlautAdaptarr/Services/ProxyRequestService.cs @@ -81,7 +81,7 @@ namespace UmlautAdaptarr.Services if (responseMessage.IsSuccessStatusCode) { - _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(12)); + _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(_options.IndexerRequestsCacheDurationInMinutes)); } return responseMessage; diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs index 26733c2..0083f7a 100644 --- a/UmlautAdaptarr/Services/TitleApiService.cs +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Text; using UmlautAdaptarr.Options; using UmlautAdaptarr.Utilities; @@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Services lastRequestTime = DateTime.Now; } - // TODO add cache, TODO add bulk request + // TODO add caching public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) { try @@ -68,6 +69,68 @@ namespace UmlautAdaptarr.Services return (null, null); } + public async Task> FetchGermanTitlesAndAliasesByExternalIdBulkAsync(IEnumerable tvdbIds) + { + try + { + await EnsureMinimumDelayAsync(); + + var httpClient = clientFactory.CreateClient(); + var bulkApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?bulk=true"; + logger.LogInformation($"TitleApiService POST {UrlUtilities.RedactApiKey(bulkApiUrl)}"); + + // Prepare POST request payload + var payload = new { tvdbIds = tvdbIds.ToArray() }; + var jsonPayload = JsonConvert.SerializeObject(payload); + var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + // Send POST request + var response = await httpClient.PostAsync(bulkApiUrl, content); + if (!response.IsSuccessStatusCode) + { + logger.LogError($"Failed to fetch German titles via bulk API. Status Code: {response.StatusCode}"); + return []; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var bulkApiResponseData = JsonConvert.DeserializeObject(responseContent); + + if (bulkApiResponseData == null || bulkApiResponseData.status != "success") + { + logger.LogError($"Parsing UmlautAdaptarr Bulk API response resulted in null or an error status."); + return []; + } + + // Process response data + var results = new Dictionary(); + foreach (var entry in bulkApiResponseData.data) + { + string tvdbId = entry.tvdbId; + string? germanTitle = entry.germanTitle; + + string[]? aliases = null; + if (entry.aliases != null) + { + JArray aliasesArray = JArray.FromObject(entry.aliases); + aliases = aliasesArray.Children() + .Select(alias => alias["name"].ToString()) + .ToArray(); + } + + results[tvdbId] = (germanTitle, aliases); + } + + logger.LogInformation($"Successfully fetched German titles for {results.Count} TVDB IDs via bulk API."); + + return results; + } + catch (Exception ex) + { + logger.LogError($"Error fetching German titles in bulk: {ex.Message}"); + return new Dictionary(); + } + } + public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title) { try diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 1969466..a60f613 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,13 +9,13 @@ - + - - + + - - + + diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index e4c90a8..446a11a 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -6,7 +6,8 @@ namespace UmlautAdaptarr.Validator; public class GlobalInstanceOptionsValidator : AbstractValidator { - private readonly static HttpClient httpClient = new() { + private readonly static HttpClient httpClient = new() + { Timeout = TimeSpan.FromSeconds(3) }; @@ -22,7 +23,7 @@ public class GlobalInstanceOptionsValidator : AbstractValidator x.ApiKey) .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); - + RuleFor(x => x) .MustAsync(BeReachable) .WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings"); @@ -65,8 +66,6 @@ public class GlobalInstanceOptionsValidator : AbstractValidator