diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12bc283 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Use the official Microsoft .NET Core SDK image as the build environment +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env +WORKDIR /app + +# Copy everything and build the project +COPY . ./ +RUN dotnet restore +RUN dotnet publish -c Release -o out + +# Generate the runtime image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build-env /app/out . +ENTRYPOINT ["dotnet", "UmlautAdaptarr.dll"] diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 3cbd4ec..6ca5ea3 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; using System.Text; using System.Xml.Linq; using UmlautAdaptarr.Models; @@ -9,15 +10,12 @@ namespace UmlautAdaptarr.Controllers { public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase { - protected readonly ProxyService _proxyService = proxyService; - protected readonly TitleMatchingService _titleMatchingService = titleMatchingService; - + private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = false; + private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; protected async Task BaseSearch(string options, string domain, IDictionary queryParameters, - string? germanTitle = null, - string? expectedTitle = null, - bool hasGermanUmlaut = false) + SearchItem? searchItem = null) { try { @@ -26,37 +24,66 @@ namespace UmlautAdaptarr.Controllers return NotFound($"{domain} is not a valid URL."); } - // Generate title variations for renaming process - var germanTitleVariations = !string.IsNullOrEmpty(germanTitle) ? _titleMatchingService.GenerateTitleVariations(germanTitle) : new List(); - - // Check if "q" parameter exists for multiple search request handling - if (hasGermanUmlaut && !string.IsNullOrEmpty(germanTitle) && !string.IsNullOrEmpty(expectedTitle) && queryParameters.ContainsKey("q")) + var initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult; + if (initialSearchResult == null) { - // Add original search query to title variations - var q = queryParameters["q"]; - if (!germanTitleVariations.Contains(q)) + return null; + } + + string inititalProcessedContent = string.Empty; + // Rename titles in the single search content + if (!string.IsNullOrEmpty(initialSearchResult?.Content)) + { + inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem?.TitleMatchVariations, searchItem?.ExpectedTitle); + } + + var additionalTextSearch = searchItem != null + && !string.IsNullOrEmpty(searchItem.ExpectedTitle) + && (TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE || TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE || + // TODO check if this is a good idea + (searchItem.TitleSearchVariations.Length > 0 && !(searchItem.TitleSearchVariations.Length == 1 && searchItem.TitleSearchVariations[0] == searchItem.ExpectedTitle))); + + if (additionalTextSearch) + { + // Aggregate the initial search result with additional results + // Remove identifiers for subsequent searches + // TODO rework this + queryParameters.Remove("tvdbid"); + queryParameters.Remove("tvmazeid"); + queryParameters.Remove("imdbid"); + + var titleSearchVariations = new List(searchItem?.TitleSearchVariations); + + string searchQuery = string.Empty; + if (queryParameters.TryGetValue("q", out string? q)) { - germanTitleVariations.Add(queryParameters["q"]!); + searchQuery = q ?? string.Empty; + // Add original search query to title variations + if (!titleSearchVariations.Remove(searchQuery)) + { + titleSearchVariations.Add(searchQuery); + } + } + + var expectedTitle = searchItem.ExpectedTitle; + + if (TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE) + { + if (expectedTitle != searchQuery && !titleSearchVariations.Contains(expectedTitle)) + { + titleSearchVariations.Add(expectedTitle); + } } // Handle multiple search requests based on German title variations - var aggregatedResult = await AggregateSearchResults(domain, queryParameters, germanTitleVariations, expectedTitle); - // Rename titles in the aggregated content - var processedContent = ProcessContent(aggregatedResult.Content, germanTitleVariations, expectedTitle); - return Content(processedContent, aggregatedResult.ContentType, aggregatedResult.ContentEncoding); - } - else - { - var singleSearchResult = await PerformSingleSearchRequest(domain, queryParameters); - // Rename titles in the single search content - var contentResult = singleSearchResult as ContentResult; - if (contentResult != null) - { - var processedContent = ProcessContent(contentResult.Content ?? "", germanTitleVariations, expectedTitle); - return Content(processedContent, contentResult.ContentType!, Encoding.UTF8); - } - return singleSearchResult; + var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem.TitleMatchVariations, expectedTitle); + aggregatedResult.AggregateItems(inititalProcessedContent); + + return Content(aggregatedResult.Content, aggregatedResult.ContentType, aggregatedResult.ContentEncoding); } + + initialSearchResult!.Content = inititalProcessedContent; + return initialSearchResult; } catch (Exception ex) { @@ -70,7 +97,7 @@ namespace UmlautAdaptarr.Controllers private async Task PerformSingleSearchRequest(string domain, IDictionary queryParameters) { var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); - var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); + var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); var content = await responseMessage.Content.ReadAsStringAsync(); var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? @@ -82,18 +109,17 @@ namespace UmlautAdaptarr.Controllers } - private string ProcessContent(string content, List germanTitleVariations, string? expectedTitle) + private string ProcessContent(string content, string[]? titleMatchVariations = null, string? expectedTitle = null) { - // Check if German title and expected title are provided for renaming - if (!string.IsNullOrEmpty(expectedTitle) && germanTitleVariations.Count != 0) - { - // Process and rename titles in the content - content = _titleMatchingService.RenameTitlesInContent(content, germanTitleVariations, expectedTitle); - } - return content; + return titleMatchingService.RenameTitlesInContent(content, titleMatchVariations, expectedTitle); } - public async Task AggregateSearchResults(string domain, IDictionary queryParameters, List germanTitleVariations, string expectedTitle) + public async Task AggregateSearchResults( + string domain, + IDictionary queryParameters, + IEnumerable titleSearchVariations, + string[] titleMatchVariations, + string expectedTitle) { string defaultContentType = "application/xml"; Encoding defaultEncoding = Encoding.UTF8; @@ -101,23 +127,23 @@ namespace UmlautAdaptarr.Controllers var aggregatedResult = new AggregatedSearchResult(defaultContentType, defaultEncoding); - foreach (var titleVariation in germanTitleVariations.Distinct()) + foreach (var titleVariation in titleSearchVariations) { queryParameters["q"] = titleVariation; // Replace the "q" parameter for each variation var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); - var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); + var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); var content = await responseMessage.Content.ReadAsStringAsync(); // Only update encoding from the first response if (!encodingSet && responseMessage.Content.Headers.ContentType?.CharSet != null) { - aggregatedResult.ContentEncoding = Encoding.GetEncoding(responseMessage.Content.Headers.ContentType.CharSet); ; + aggregatedResult.ContentEncoding = Encoding.GetEncoding(responseMessage.Content.Headers.ContentType.CharSet); aggregatedResult.ContentType = responseMessage.Content.Headers.ContentType?.MediaType ?? defaultContentType; encodingSet = true; } // Process and rename titles in the content - content = _titleMatchingService.RenameTitlesInContent(content, germanTitleVariations, expectedTitle); + content = ProcessContent(content, titleMatchVariations, expectedTitle); // Aggregate the items into a single document aggregatedResult.AggregateItems(content); @@ -129,7 +155,7 @@ namespace UmlautAdaptarr.Controllers public class SearchController(ProxyService proxyService, TitleMatchingService titleMatchingService, - TitleQueryService titleQueryService) : SearchControllerBase(proxyService, titleMatchingService) + SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService) { [HttpGet] public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) @@ -165,54 +191,19 @@ namespace UmlautAdaptarr.Controllers q => q.Key, q => string.Join(",", q.Value)); - string? searchKey = null; - string? searchValue = null; + SearchItem? searchItem = null; + string mediaType = "tv"; - if (queryParameters.TryGetValue("tvdbid", out string? tvdbId)) + if (queryParameters.TryGetValue("tvdbid", out string? tvdbId) && !string.IsNullOrEmpty(tvdbId)) { - searchKey = "tvdbid"; - searchValue = tvdbId; + searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, tvdbId); } - else if (queryParameters.TryGetValue("q", out string? title)) + else if (queryParameters.TryGetValue("q", out string? title) && !string.IsNullOrEmpty(title)) { - searchKey = "q"; - searchValue = title; + searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title); } - // Perform the search if a valid search key was identified - if (searchKey != null && searchValue != null) - { - var (hasGermanUmlaut, germanTitle, expectedTitle) = searchKey == "tvdbid" - ? await titleQueryService.QueryGermanShowTitleByTVDBId(searchValue) - : await titleQueryService.QueryGermanShowTitleByTitle(searchValue); - - if (!string.IsNullOrEmpty(germanTitle) && !string.IsNullOrEmpty(expectedTitle)) - { - var initialSearchResult = await BaseSearch(options, domain, queryParameters, germanTitle, expectedTitle, hasGermanUmlaut); - - // Additional search with german title because the automatic tvdbid association often fails at the indexer too if there are umlauts - if (hasGermanUmlaut && searchKey == "tvdbid") - { - // Remove identifiers for subsequent searches - queryParameters.Remove("tvdbid"); - queryParameters.Remove("tvmazeid"); - queryParameters.Remove("imdbid"); - - // Aggregate the initial search result with additional results - var germanTitleVariations = _titleMatchingService.GenerateTitleVariations(germanTitle); - var aggregatedResult = await AggregateSearchResults(domain, queryParameters, germanTitleVariations, expectedTitle); - // todo processedContent wie in BaseSearch - - - aggregatedResult.AggregateItems((initialSearchResult as ContentResult)?.Content ?? ""); - - return Content(aggregatedResult.Content, aggregatedResult.ContentType, aggregatedResult.ContentEncoding); - } - return initialSearchResult; - } - } - - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(options, domain, queryParameters, searchItem); } [HttpGet] diff --git a/UmlautAdaptarr/Models/AggregatedSearchResult.cs b/UmlautAdaptarr/Models/AggregatedSearchResult.cs index 0484236..b00b6ff 100644 --- a/UmlautAdaptarr/Models/AggregatedSearchResult.cs +++ b/UmlautAdaptarr/Models/AggregatedSearchResult.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Text; +using System.Text; using System.Xml.Linq; namespace UmlautAdaptarr.Models @@ -9,13 +8,13 @@ namespace UmlautAdaptarr.Models public XDocument ContentDocument { get; private set; } public string ContentType { get; set; } public Encoding ContentEncoding { get; set; } - private HashSet _uniqueItems; + private readonly HashSet _uniqueItems; public AggregatedSearchResult(string contentType, Encoding contentEncoding) { ContentType = contentType; ContentEncoding = contentEncoding; - _uniqueItems = new HashSet(); + _uniqueItems = []; // Initialize ContentDocument with a basic RSS structure ContentDocument = new XDocument(new XElement("rss", new XElement("channel"))); @@ -35,10 +34,6 @@ namespace UmlautAdaptarr.Models { ContentDocument.Root.Element("channel").Add(item); } - else - { - - } } } } diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs new file mode 100644 index 0000000..9acca3b --- /dev/null +++ b/UmlautAdaptarr/Models/SearchItem.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Models +{ + public partial class SearchItem + { + public int ArrId { get; set; } + public string ExternalId { get; set; } + public string Title { get; set; } + public bool HasGermanUmlaut => Title?.HasGermanUmlauts() ?? false; + public string ExpectedTitle { get; set; } + public string? GermanTitle { get; set; } + public string[] TitleSearchVariations { get; set; } + public string[] TitleMatchVariations { get; set; } + public string MediaType { get; set; } + // TODO public MediaType instead of string + + public SearchItem(int arrId, string externalId, string title, string expectedTitle, string? germanTitle, string mediaType, string[]? aliases) + { + ArrId = arrId; + ExternalId = externalId; + Title = title; + ExpectedTitle = expectedTitle; + GermanTitle = germanTitle; + TitleSearchVariations = GenerateTitleVariations(germanTitle).ToArray(); + MediaType = mediaType; + + var allTitleVariations = new List(TitleSearchVariations); + + // If aliases are not null, generate variations for each and add them to the list + // TODO (not necessarily here) only use deu and eng alias + if (aliases != null) + { + foreach (var alias in aliases) + { + allTitleVariations.AddRange(GenerateTitleVariations(alias)); + } + } + + TitleMatchVariations = allTitleVariations.Distinct().ToArray(); + } + + private IEnumerable GenerateTitleVariations(string? germanTitle) + { + if (germanTitle == null) + { + return []; + } + var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts(); + + // Start with base variations including handling umlauts + var baseVariations = new List + { + cleanTitle, // No change + cleanTitle.ReplaceGermanUmlautsWithLatinEquivalents(), + cleanTitle.RemoveGermanUmlautDots() + }; + + // TODO: determine if this is really needed + // Additional variations to accommodate titles with "-" + if (cleanTitle.Contains('-')) + { + var withoutDash = cleanTitle.Replace("-", ""); + var withSpaceInsteadOfDash = cleanTitle.Replace("-", " "); + + // Add variations of the title without dash and with space instead of dash + baseVariations.AddRange(new List + { + withoutDash, + withSpaceInsteadOfDash, + withoutDash.ReplaceGermanUmlautsWithLatinEquivalents(), + withoutDash.RemoveGermanUmlautDots(), + withSpaceInsteadOfDash.ReplaceGermanUmlautsWithLatinEquivalents(), + withSpaceInsteadOfDash.RemoveGermanUmlautDots() + }); + } + + // Remove multiple spaces + var cleanedVariations = baseVariations.Select(variation => MultipleWhitespaceRegex().Replace(variation, " ")); + + return cleanedVariations.Distinct(); + } + + [GeneratedRegex(@"\s+")] + private static partial Regex MultipleWhitespaceRegex(); + } +} \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 2e5e465..38ed8df 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,4 +1,5 @@ using System.Net; +using UmlautAdaptarr.Providers; using UmlautAdaptarr.Routing; using UmlautAdaptarr.Services; @@ -29,14 +30,17 @@ internal class Program builder.Services.AddMemoryCache(options => { - options.SizeLimit = 500; - }); - builder.Services.AddSingleton>(); + //options.SizeLimit = 20000; + }); builder.Services.AddControllers(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddHostedService(); + builder.Services.AddSingleton(); // TODO rename + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json index 54ed8e4..37160f1 100644 --- a/UmlautAdaptarr/Properties/launchSettings.json +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -2,12 +2,10 @@ "profiles": { "http": { "commandName": "Project", - "launchBrowser": true, - "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", - "launchUrl": "/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, + "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", "dotnetRunMessages": true, "applicationUrl": "http://localhost:5182" }, diff --git a/UmlautAdaptarr/Providers/ArrClientBase.cs b/UmlautAdaptarr/Providers/ArrClientBase.cs new file mode 100644 index 0000000..80a89ce --- /dev/null +++ b/UmlautAdaptarr/Providers/ArrClientBase.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Caching.Memory; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Services; + +namespace UmlautAdaptarr.Providers +{ + public abstract class ArrClientBase() + { + public abstract Task> FetchAllItemsAsync(); + public abstract Task FetchItemByExternalIdAsync(string externalId); + public abstract Task FetchItemByTitleAsync(string title); + } +} diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs new file mode 100644 index 0000000..b23dcba --- /dev/null +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -0,0 +1,172 @@ +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using System.Net.Http; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Services; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Providers +{ + public class SonarrClient( + IHttpClientFactory clientFactory, + IConfiguration configuration, + TitleApiService titleService, + ILogger logger) : ArrClientBase() + { + private readonly string _sonarrHost = configuration.GetValue("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set"); + private readonly string _sonarrApiKey = configuration.GetValue("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set"); + private readonly string _mediaType = "tv"; + + public override async Task> FetchAllItemsAsync() + { + var httpClient = clientFactory.CreateClient(); + var items = new List(); + + try + { + + var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}"; + 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 = $"{_sonarrHost}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + 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) + { + var tvdbId = (string)show.tvdbId; + if (tvdbId == null) + { + logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId."); + return null; + } + (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 + ); + + logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); + return searchItem; + } + } + catch (Exception ex) + { + logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); + } + + return null; + } + + public override async Task FetchItemByTitleAsync(string title) + { + var httpClient = clientFactory.CreateClient(); + + try + { + (string? germanTitle, string? tvdbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title); + + if (tvdbId == null) + { + return null; + } + + var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(sonarrApiResponse); + + if (shows == null) + { + logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); + return 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 searchItem = new SearchItem + ( + arrId: (int)shows[0].id, + externalId: tvdbId, + title: (string)shows[0].title, + expectedTitle: (string)shows[0].title, + germanTitle: germanTitle, + aliases: aliases, + mediaType: _mediaType + ); + + logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); + return searchItem; + } + catch (Exception ex) + { + logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); + } + + return null; + } + } +} diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs new file mode 100644 index 0000000..5699a33 --- /dev/null +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -0,0 +1,75 @@ +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; + +namespace UmlautAdaptarr.Services +{ + public class ArrSyncBackgroundService( + SonarrClient sonarrClient, + CacheService cacheService, + ILogger logger) : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("ArrSyncBackgroundService is starting."); + + while (!stoppingToken.IsCancellationRequested) + { + logger.LogInformation("ArrSyncBackgroundService is running."); + await FetchAndUpdateDataAsync(); + logger.LogInformation("ArrSyncBackgroundService has completed an iteration."); + + await Task.Delay(TimeSpan.FromHours(12), stoppingToken); + } + + logger.LogInformation("ArrSyncBackgroundService is stopping."); + } + + private async Task FetchAndUpdateDataAsync() + { + try + { + await FetchItemsFromSonarrAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while fetching items from the Arrs."); + } + } + + private async Task FetchItemsFromSonarrAsync() + { + try + { + var items = await sonarrClient.FetchAllItemsAsync(); + UpdateSearchItems(items); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while updating search item from Sonarr."); + } + } + + 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}."); + } + } + } + } +} diff --git a/UmlautAdaptarr/Services/CacheService.cs b/UmlautAdaptarr/Services/CacheService.cs new file mode 100644 index 0000000..9344c3d --- /dev/null +++ b/UmlautAdaptarr/Services/CacheService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Caching.Memory; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Services +{ + public class CacheService(IMemoryCache cache) + { + private readonly Dictionary> VariationIndex = []; + private const int VARIATION_LOOKUP_CACHE_LENGTH = 5; + + public void CacheSearchItem(SearchItem item) + { + var prefix = item.MediaType; + var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); + // TODO maybe we need to also add the media type (movie/book/show etc) + + cache.Set($"{prefix}_extid_{item.ExternalId}", item); + cache.Set($"{prefix}_title_{normalizedTitle}", item); + + foreach (var variation in item.TitleSearchVariations) + { + var normalizedVariation = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); + var cacheKey = $"{prefix}_var_{normalizedVariation}"; + cache.Set(cacheKey, item); + + // Indexing by prefix + var indexPrefix = normalizedVariation[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, variation.Length)].ToLower(); + if (!VariationIndex.ContainsKey(indexPrefix)) + { + VariationIndex[indexPrefix] = new HashSet(); + } + VariationIndex[indexPrefix].Add(cacheKey); + } + } + + public SearchItem? SearchItemByTitle(string mediaType, string title) + { + var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); + + // Use the first few characters of the normalized title for cache prefix search + var cacheSearchPrefix = normalizedTitle[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, normalizedTitle.Length)]; + + if (VariationIndex.TryGetValue(cacheSearchPrefix, out var cacheKeys)) + { + foreach (var cacheKey in cacheKeys) + { + if (cache.TryGetValue(cacheKey, out SearchItem? item)) + { + if (item?.MediaType != mediaType) + { + continue; + } + // After finding a potential item, compare normalizedTitle with each German title variation + foreach (var variation in item?.TitleSearchVariations ?? []) + { + var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower(); + if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) + { + return item; + } + } + } + } + } + + return null; + } + + public SearchItem? GetSearchItemByExternalId(string mediaType, string externalId) + { + if (cache.TryGetValue($"{mediaType}_extid_{externalId}", out SearchItem? item)) + { + return item; + } + return null; + } + + public SearchItem? GetSearchItemByTitle(string mediaType, string title) + { + var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); + + if (mediaType == "generic") + { + // TODO + } + cache.TryGetValue($"{mediaType}_var_{normalizedTitle}", out SearchItem? item); + if (item == null) + { + cache.TryGetValue($"{mediaType}_title_{normalizedTitle}", out item); + } + return item; + } + } +} diff --git a/UmlautAdaptarr/Services/ProxyService.cs b/UmlautAdaptarr/Services/ProxyService.cs index cd56ed5..20f21d3 100644 --- a/UmlautAdaptarr/Services/ProxyService.cs +++ b/UmlautAdaptarr/Services/ProxyService.cs @@ -1,22 +1,58 @@ -namespace UmlautAdaptarr.Services +using Microsoft.Extensions.Caching.Memory; +using System.Collections.Concurrent; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Services { - public class ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration) + public class ProxyService { - 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"); - // TODO: Add cache! + private readonly HttpClient _httpClient; + private readonly string _userAgent; + private readonly ILogger _logger; + private readonly IMemoryCache _cache; + private static readonly ConcurrentDictionary _lastRequestTimes = new(); + + public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger logger, IMemoryCache cache) + { + _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(nameof(clientFactory)); + _userAgent = configuration["Settings:UserAgent"] ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); + _logger = logger; + _cache = cache; + } public async Task ProxyRequestAsync(HttpContext context, string targetUri) { - var requestMessage = new HttpRequestMessage(); - var requestMethod = context.Request.Method; - - if (!HttpMethods.IsGet(requestMethod)) + if (!HttpMethods.IsGet(context.Request.Method)) { - throw new ArgumentException("Only GET requests are supported", nameof(requestMethod)); + throw new ArgumentException("Only GET requests are supported", context.Request.Method); } - // Copy the request headers + // Throttling mechanism + var host = new Uri(targetUri).Host; + if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime)) + { + var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime; + if (timeSinceLastRequest < TimeSpan.FromSeconds(3)) + { + await Task.Delay(TimeSpan.FromSeconds(3) - timeSinceLastRequest); + } + } + _lastRequestTimes[host] = DateTimeOffset.Now; + + // Check cache + if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse)) + { + _logger.LogInformation($"Returning cached response for {UrlUtilities.RedactApiKey(targetUri)}"); + return cachedResponse!; + } + + var requestMessage = new HttpRequestMessage + { + RequestUri = new Uri(targetUri), + Method = HttpMethod.Get, + }; + + // Copy request headers foreach (var header in context.Request.Headers) { if (header.Key == "User-Agent" && _userAgent.Length != 0) @@ -29,34 +65,29 @@ } } - 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); + _logger.LogInformation($"ProxyService GET {UrlUtilities.RedactApiKey(targetUri)}"); + var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); - // TODO: Handle 503 etc - responseMessage.EnsureSuccessStatusCode(); - - // Modify the response content if necessary - /*var content = await responseMessage.Content.ReadAsStringAsync(); - content = ReplaceCharacters(content); - responseMessage.Content = new StringContent(content);*/ + if (responseMessage.IsSuccessStatusCode) + { + _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(5)); + } return responseMessage; } catch (Exception ex) { - Console.WriteLine(ex.ToString()); - return null; - } - } + _logger.LogError(ex, $"Error proxying request: {UrlUtilities.RedactApiKey(targetUri)}. Error: {ex.Message}"); - private string ReplaceCharacters(string input) - { - return input.Replace("Ä", "AE"); + // Create a response message indicating an internal server error + var errorResponse = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError) + { + Content = new StringContent($"An error occurred while processing your request: {ex.Message}") + }; + return errorResponse; + } } } } diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs new file mode 100644 index 0000000..64529d5 --- /dev/null +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -0,0 +1,65 @@ +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Providers; + +namespace UmlautAdaptarr.Services +{ + public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient) + { + public async Task GetOrFetchSearchItemByExternalId(string mediaType, string externalId) + { + // Attempt to get the item from the cache first + var cachedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId); + if (cachedItem != null) + { + return cachedItem; + } + + // If not found in cache, fetch from the appropriate source + SearchItem? fetchedItem = null; + switch (mediaType) + { + case "tv": + fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); + break; + // TODO Add cases for other sources like Radarr, Lidarr, etc. + } + + // If an item is fetched, cache it + if (fetchedItem != null) + { + cacheService.CacheSearchItem(fetchedItem); + } + + return fetchedItem; + } + + public async Task GetOrFetchSearchItemByTitle(string mediaType, string title) + { + // Attempt to get the item from the cache first + var cachedItem = cacheService.GetSearchItemByTitle(mediaType, title); + if (cachedItem != null) + { + return cachedItem; + } + + // If not found in cache, fetch from the appropriate source + SearchItem? fetchedItem = null; + switch (mediaType) + { + case "tv": + fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); + break; + // TODO add cases for other sources as needed, such as Radarr, Lidarr, etc. + } + + // If an item is fetched, cache it + if (fetchedItem != null) + { + cacheService.CacheSearchItem(fetchedItem); + } + + return fetchedItem; + } + } + +} diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs new file mode 100644 index 0000000..a501d9c --- /dev/null +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -0,0 +1,98 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace UmlautAdaptarr.Services +{ + public class TitleApiService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger logger) + { + private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] + ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json"); + private DateTime lastRequestTime = DateTime.MinValue; + + private async Task EnsureMinimumDelayAsync() + { + var sinceLastRequest = DateTime.Now - lastRequestTime; + if (sinceLastRequest < TimeSpan.FromSeconds(2)) + { + await Task.Delay(TimeSpan.FromSeconds(2) - sinceLastRequest); + } + lastRequestTime = DateTime.Now; + } + + public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) + { + try + { + await EnsureMinimumDelayAsync(); + + var httpClient = clientFactory.CreateClient(); + var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; + var response = await httpClient.GetStringAsync(titleApiUrl); + var titleApiResponseData = JsonConvert.DeserializeObject(response); + + if (titleApiResponseData == null) + { + logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for mediaType {mediaType} with external id {externalId} resulted in null"); + return (null, null); + } + + if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) + { + // TODO add filter for german aliases only in API + // then also add if there is a "deu" alias to search for it via text + string[]? aliases = null; + + if (titleApiResponseData.aliases != null) + { + // Parse the aliases as a JArray + JArray aliasesArray = JArray.FromObject(titleApiResponseData.aliases); + + // Project the 'name' field from each object in the array + aliases = aliasesArray.Children() + .Select(alias => alias["name"].ToString()) + .ToArray(); + } + return (titleApiResponseData.germanTitle, aliases); + } + } + catch (Exception ex) + { + logger.LogError($"Error fetching German title for TVDB ID {externalId}: {ex.Message}"); + } + + return (null, null); + } + + public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title) + { + try + { + await EnsureMinimumDelayAsync(); + + var httpClient = clientFactory.CreateClient(); + var tvdbCleanTitle = title.Replace("ß", "ss"); + var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; + var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl); + var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); + + if (titleApiResponseData == null) + { + logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for title {title} resulted in null"); + return (null, null, null); + } + + if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) + { + string[] aliases = titleApiResponseData.aliases.ToObject(); + return (titleApiResponseData.germanTitle, titleApiResponseData.tvdbId, aliases); + } + } + catch (Exception ex) + { + logger.LogError($"Error fetching German title for {mediaType} with title {title}: {ex.Message}"); + } + + return (null, null, null); + } + } +} diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 5f00967..59e10c1 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -4,46 +4,15 @@ using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Services { - public partial class TitleMatchingService + public partial class TitleMatchingService(CacheService cacheService, ILogger logger) { - public List GenerateTitleVariations(string germanTitle) - { - var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts(); - - // Start with base variations including handling umlauts - var baseVariations = new List - { - cleanTitle, // No change - cleanTitle.ReplaceGermanUmlautsWithLatinEquivalents(), - cleanTitle.RemoveGermanUmlautDots() - }; - - // Additional variations to accommodate titles with "-" - if (cleanTitle.Contains('-')) - { - var withoutDash = cleanTitle.Replace("-", ""); - var withSpaceInsteadOfDash = cleanTitle.Replace("-", " "); - - // Add variations of the title without dash and with space instead of dash - baseVariations.AddRange(new List - { - withoutDash, - withSpaceInsteadOfDash, - withoutDash.ReplaceGermanUmlautsWithLatinEquivalents(), - withoutDash.RemoveGermanUmlautDots(), - withSpaceInsteadOfDash.ReplaceGermanUmlautsWithLatinEquivalents(), - withSpaceInsteadOfDash.RemoveGermanUmlautDots() - }); - } - - return baseVariations.Distinct().ToList(); - } - - - public string RenameTitlesInContent(string content, List germanTitleVariations, string expectedTitle) + public string RenameTitlesInContent(string content, string[]? titleMatchVariations, string? expectedTitle) { var xDoc = XDocument.Parse(content); + // If expectedTitle and titleMatchVariations are provided use them, if not use the CacheService to find matches. + bool useCacheService = string.IsNullOrEmpty(expectedTitle) || titleMatchVariations?.Length == 0; + foreach (var item in xDoc.Descendants("item")) { var titleElement = item.Element("title"); @@ -52,9 +21,40 @@ namespace UmlautAdaptarr.Services var originalTitle = titleElement.Value; var normalizedOriginalTitle = NormalizeTitle(originalTitle); - // Attempt to find a variation that matches the start of the original title - foreach (var variation in germanTitleVariations) + if (useCacheService) { + var categoryElement = item.Element("category"); + var category = categoryElement?.Value; + var mediaType = GetMediaTypeFromCategory(category); + if (mediaType == null) + { + continue; + } + + // Use CacheService to find a matching SearchItem by title + var searchItem = cacheService.SearchItemByTitle(mediaType, originalTitle); + if (searchItem != null) + { + // If a SearchItem is found, use its ExpectedTitle and titleMatchVariations for renaming + expectedTitle = searchItem.ExpectedTitle; + titleMatchVariations = searchItem.TitleMatchVariations; + } + else + { + // Skip processing this item if no matching SearchItem is found + continue; + } + } + + // Attempt to find a variation that matches the start of the original title + foreach (var variation in titleMatchVariations!) + { + // Skip variations that are already the expectedTitle + if (variation == expectedTitle) + { + continue; + } + // Variation is already normalized at creation var pattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]"); @@ -64,7 +64,7 @@ namespace UmlautAdaptarr.Services // Find the first separator used in the original title for consistent replacement var separator = FindFirstSeparator(originalTitle); // Reconstruct the expected title using the original separator - var newTitlePrefix = expectedTitle.Replace(" ", separator.ToString()); + var newTitlePrefix = expectedTitle!.Replace(" ", separator.ToString()); // Extract the suffix from the original title starting right after the matched variation length var variationLength = variation.Length; @@ -88,7 +88,10 @@ namespace UmlautAdaptarr.Services var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix); // Update the title element's value with the new title - titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})"; + //titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})"; + titleElement.Value = newTitle; + + logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'"); break; // Break after the first successful match and modification } } @@ -102,7 +105,7 @@ namespace UmlautAdaptarr.Services private static string NormalizeTitle(string title) { title = title.RemoveAccentButKeepGermanUmlauts(); - // Replace all known separators with a consistent one for normalization + // Replace all known separators with space for normalization return WordSeperationCharRegex().Replace(title, " ".ToString()); } @@ -118,6 +121,33 @@ namespace UmlautAdaptarr.Services return title.Replace(' ', separator); } + public string? GetMediaTypeFromCategory(string? category) + { + if (category == null) + { + return null; + } + + if (category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase)) + { + return "book"; + } + else if (category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase)) + { + return "movies"; + } + else if (category.StartsWith("TV", StringComparison.OrdinalIgnoreCase)) + { + return "tv"; + } + else if (category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase)) + { + return "book"; + } + + return null; + } + [GeneratedRegex("[._ ]")] private static partial Regex WordSeperationCharRegex(); diff --git a/UmlautAdaptarr/Services/TitleQueryService.cs b/UmlautAdaptarr/Services/TitleQueryService.cs deleted file mode 100644 index 5aa934b..0000000 --- a/UmlautAdaptarr/Services/TitleQueryService.cs +++ /dev/null @@ -1,187 +0,0 @@ -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; - private readonly string _umlautAdaptarrApiHost; - - public TitleQueryService(IMemoryCache memoryCache, ILogger logger, IConfiguration configuration, IHttpClientFactory clientFactory) - { - _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(); - _cache = memoryCache; - _logger = logger; - - _sonarrHost = configuration.GetValue("SONARR_HOST"); - _sonarrApiKey = configuration.GetValue("SONARR_API_KEY"); - _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json"); - - } - - public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTVDBId(string tvdbId) - { - var 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 = (string)shows[0].title; - if (expectedTitle == null) - { - _logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); - return (false, null, string.Empty); - } - - string? germanTitle = null; - var hasGermanTitle = false; - var originalLanguage = (string)shows[0].originalLanguage.name; - - if (originalLanguage != "German") - { - var apiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={tvdbId}"; - var apiResponse = await _httpClient.GetStringAsync(apiUrl); - var responseData = JsonConvert.DeserializeObject(apiResponse); - - if (responseData == null) - { - _logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for TVDB ID {tvdbId} resulted in null"); - return (false, null, string.Empty); - } - - if (responseData.status == "success" && !string.IsNullOrEmpty((string)responseData.germanTitle)) - { - germanTitle = responseData.germanTitle; - hasGermanTitle = true; - } - } - else - { - germanTitle = expectedTitle; - hasGermanTitle = true; - } - - var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; - - var result = (hasGermanUmlaut, germanTitle, expectedTitle); - _cache.Set(cacheKey, result, new MemoryCacheEntryOptions - { - Size = 1, - SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) - }); - - return result; - } - - public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTitle(string title) - { - // TVDB doesn't use ß - var tvdbCleanTitle = title.Replace("ß", "ss"); - - var cacheKey = $"show_{tvdbCleanTitle}"; - if (_cache.TryGetValue(cacheKey, out (bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle) cachedResult)) - { - return cachedResult; - } - - var apiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; - var apiResponse = await _httpClient.GetStringAsync(apiUrl); - var responseData = JsonConvert.DeserializeObject(apiResponse); - - if (responseData == null) - { - _logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for title {title} resulted in null"); - return (false, null, string.Empty); - } - - if (responseData.status == "success" && !string.IsNullOrEmpty((string)responseData.germanTitle)) - { - var tvdbId = (string)responseData.tvdbId; - if (tvdbId == null) - { - _logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response tvdbId {responseData} resulted in null"); - return (false, null, string.Empty); - } - - 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 = (string)shows[0].title; - if (expectedTitle == null) - { - _logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); - return (false, null, string.Empty); - } - - string germanTitle ; - bool hasGermanTitle; - var originalLanguage = (string)shows[0].originalLanguage.name; - - if (originalLanguage != "German") - { - germanTitle = responseData.germanTitle; - hasGermanTitle = true; - } - else - { - germanTitle = expectedTitle; - hasGermanTitle = true; - } - - var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; - - var result = (hasGermanUmlaut, germanTitle, expectedTitle); - _cache.Set(cacheKey, result, new MemoryCacheEntryOptions - { - Size = 1, - SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) - }); - - return result; - } - else - { - _logger.LogWarning($"UmlautAdaptarr TitleQuery { apiUrl } didn't succeed."); - return (false, null, string.Empty); - } - } - - } -} diff --git a/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs b/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs new file mode 100644 index 0000000..c606464 --- /dev/null +++ b/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Providers; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Services +{ + public class TitleQueryServiceLegacy( + IMemoryCache memoryCache, + ILogger logger, + IConfiguration configuration, + IHttpClientFactory clientFactory, + SonarrClient sonarrClient) + { + private readonly HttpClient _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(); + private readonly string _sonarrHost = configuration.GetValue("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set"); + private readonly string _sonarrApiKey = configuration.GetValue("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set"); + private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json"); + + /*public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTVDBId(string tvdbId) + { + var sonarrCacheKey = $"SearchItem_Sonarr_{tvdbId}"; + + if (memoryCache.TryGetValue(sonarrCacheKey, out SearchItem? cachedItem)) + { + return (cachedItem?.HasGermanUmlaut ?? false, cachedItem?.GermanTitle, cachedItem?.ExpectedTitle ?? string.Empty); + } + else + { + var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + var sonarrApiResponse = await _httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(sonarrApiResponse); + + if (shows == null) + { + logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); + return (false, null, string.Empty); + } + else if (shows.Count == 0) + { + logger.LogWarning($"No results found for TVDB ID {tvdbId}"); + return (false, null, string.Empty); + } + + var expectedTitle = (string)shows[0].title; + if (expectedTitle == null) + { + logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); + return (false, null, string.Empty); + } + + string? germanTitle = null; + var hasGermanTitle = false; + + var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={tvdbId}"; + var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl); + var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); + + if (titleApiResponseData == null) + { + logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for TVDB ID {tvdbId} resulted in null"); + return (false, null, string.Empty); + } + + if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) + { + germanTitle = titleApiResponseData.germanTitle; + hasGermanTitle = true; + } + + var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; + + var result = (hasGermanUmlaut, germanTitle, expectedTitle); + memoryCache.Set(showCacheKey, result, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) + }); + + return result; + } + }*/ + + // This method is being used if the *arrs do a search with the "q" parameter (text search) + public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTitle(string title) + { + // TVDB doesn't use ß - TODO: Determine if this is true + var tvdbCleanTitle = title.Replace("ß", "ss"); + + var cacheKey = $"show_{tvdbCleanTitle}"; + if (memoryCache.TryGetValue(cacheKey, out (bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle) cachedResult)) + { + return cachedResult; + } + + var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; + var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl); + var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); + + if (titleApiResponseData == null) + { + logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for title {title} resulted in null"); + return (false, null, string.Empty); + } + + if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle)) + { + var tvdbId = (string)titleApiResponseData.tvdbId; + if (tvdbId == null) + { + logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response tvdbId {titleApiResponseData} resulted in null"); + return (false, null, string.Empty); + } + + var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; + var sonarrApiResponse = await _httpClient.GetStringAsync(sonarrUrl); + var shows = JsonConvert.DeserializeObject(sonarrApiResponse); + + if (shows == null) + { + logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); + return (false, null, string.Empty); + } + else if (shows.Count == 0) + { + logger.LogWarning($"No results found for TVDB ID {tvdbId}"); + return (false, null, string.Empty); + } + + var expectedTitle = (string)shows[0].title; + if (expectedTitle == null) + { + logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); + return (false, null, string.Empty); + } + + string germanTitle ; + bool hasGermanTitle; + + germanTitle = titleApiResponseData.germanTitle; + hasGermanTitle = true; + + var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false; + + var result = (hasGermanUmlaut, germanTitle, expectedTitle); + memoryCache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7) + }); + + return result; + } + else + { + logger.LogWarning($"UmlautAdaptarr TitleQuery {titleApiUrl} didn't succeed."); + return (false, null, string.Empty); + } + } + } +} diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index b1c8592..5b1fa31 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -10,7 +10,7 @@ - + diff --git a/UmlautAdaptarr/Utilities/UrlUtilities.cs b/UmlautAdaptarr/Utilities/UrlUtilities.cs index f90cfe3..937d1aa 100644 --- a/UmlautAdaptarr/Utilities/UrlUtilities.cs +++ b/UmlautAdaptarr/Utilities/UrlUtilities.cs @@ -40,5 +40,14 @@ namespace UmlautAdaptarr.Utilities return BuildUrl(domain, queryParameters); } + + public static string RedactApiKey(string targetUri) + { + var apiKeyPattern = @"(apikey=)[^&]*"; + + var redactedUri = Regex.Replace(targetUri, apiKeyPattern, "$1[REDACTED]"); + + return redactedUri; + } } } diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index a75a43f..7255729 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -9,7 +9,7 @@ "Kestrel": { "Endpoints": { "Http": { - "Url": "http://localhost:5005" + "Url": "http://*:5005" } } }, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7ef30b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' +services: + umlautadaptarr: + build: . + environment: + SONARR_HOST: "http://localhost:8989" + SONARR_API_KEY: "" + ports: + - "5005:5005"