Intermediate commit

This commit is contained in:
pcjones
2024-02-12 01:57:41 +01:00
parent abc2c84aa2
commit 27ba8a1f19
20 changed files with 1027 additions and 365 deletions

14
Dockerfile Normal file
View File

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

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using System.Text; using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
@@ -9,15 +10,12 @@ namespace UmlautAdaptarr.Controllers
{ {
public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase
{ {
protected readonly ProxyService _proxyService = proxyService; private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = false;
protected readonly TitleMatchingService _titleMatchingService = titleMatchingService; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options, protected async Task<IActionResult> BaseSearch(string options,
string domain, string domain,
IDictionary<string, string> queryParameters, IDictionary<string, string> queryParameters,
string? germanTitle = null, SearchItem? searchItem = null)
string? expectedTitle = null,
bool hasGermanUmlaut = false)
{ {
try try
{ {
@@ -26,37 +24,66 @@ namespace UmlautAdaptarr.Controllers
return NotFound($"{domain} is not a valid URL."); return NotFound($"{domain} is not a valid URL.");
} }
// Generate title variations for renaming process var initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult;
var germanTitleVariations = !string.IsNullOrEmpty(germanTitle) ? _titleMatchingService.GenerateTitleVariations(germanTitle) : new List<string>(); if (initialSearchResult == null)
{
return null;
}
// Check if "q" parameter exists for multiple search request handling string inititalProcessedContent = string.Empty;
if (hasGermanUmlaut && !string.IsNullOrEmpty(germanTitle) && !string.IsNullOrEmpty(expectedTitle) && queryParameters.ContainsKey("q")) // 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<string>(searchItem?.TitleSearchVariations);
string searchQuery = string.Empty;
if (queryParameters.TryGetValue("q", out string? q))
{
searchQuery = q ?? string.Empty;
// Add original search query to title variations // Add original search query to title variations
var q = queryParameters["q"]; if (!titleSearchVariations.Remove(searchQuery))
if (!germanTitleVariations.Contains(q))
{ {
germanTitleVariations.Add(queryParameters["q"]!); 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 // Handle multiple search requests based on German title variations
var aggregatedResult = await AggregateSearchResults(domain, queryParameters, germanTitleVariations, expectedTitle); var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem.TitleMatchVariations, expectedTitle);
// Rename titles in the aggregated content aggregatedResult.AggregateItems(inititalProcessedContent);
var processedContent = ProcessContent(aggregatedResult.Content, germanTitleVariations, expectedTitle);
return Content(processedContent, aggregatedResult.ContentType, aggregatedResult.ContentEncoding); return Content(aggregatedResult.Content, 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;
} }
initialSearchResult!.Content = inititalProcessedContent;
return initialSearchResult;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -70,7 +97,7 @@ namespace UmlautAdaptarr.Controllers
private async Task<IActionResult> PerformSingleSearchRequest(string domain, IDictionary<string, string> queryParameters) private async Task<IActionResult> PerformSingleSearchRequest(string domain, IDictionary<string, string> queryParameters)
{ {
var requestUrl = UrlUtilities.BuildUrl(domain, 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 content = await responseMessage.Content.ReadAsStringAsync();
var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ?
@@ -82,18 +109,17 @@ namespace UmlautAdaptarr.Controllers
} }
private string ProcessContent(string content, List<string> 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 return titleMatchingService.RenameTitlesInContent(content, titleMatchVariations, expectedTitle);
if (!string.IsNullOrEmpty(expectedTitle) && germanTitleVariations.Count != 0)
{
// Process and rename titles in the content
content = _titleMatchingService.RenameTitlesInContent(content, germanTitleVariations, expectedTitle);
}
return content;
} }
public async Task<AggregatedSearchResult> AggregateSearchResults(string domain, IDictionary<string, string> queryParameters, List<string> germanTitleVariations, string expectedTitle) public async Task<AggregatedSearchResult> AggregateSearchResults(
string domain,
IDictionary<string, string> queryParameters,
IEnumerable<string> titleSearchVariations,
string[] titleMatchVariations,
string expectedTitle)
{ {
string defaultContentType = "application/xml"; string defaultContentType = "application/xml";
Encoding defaultEncoding = Encoding.UTF8; Encoding defaultEncoding = Encoding.UTF8;
@@ -101,23 +127,23 @@ namespace UmlautAdaptarr.Controllers
var aggregatedResult = new AggregatedSearchResult(defaultContentType, defaultEncoding); 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 queryParameters["q"] = titleVariation; // Replace the "q" parameter for each variation
var requestUrl = UrlUtilities.BuildUrl(domain, 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 content = await responseMessage.Content.ReadAsStringAsync();
// Only update encoding from the first response // Only update encoding from the first response
if (!encodingSet && responseMessage.Content.Headers.ContentType?.CharSet != null) 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; aggregatedResult.ContentType = responseMessage.Content.Headers.ContentType?.MediaType ?? defaultContentType;
encodingSet = true; encodingSet = true;
} }
// Process and rename titles in the content // 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 // Aggregate the items into a single document
aggregatedResult.AggregateItems(content); aggregatedResult.AggregateItems(content);
@@ -129,7 +155,7 @@ namespace UmlautAdaptarr.Controllers
public class SearchController(ProxyService proxyService, public class SearchController(ProxyService proxyService,
TitleMatchingService titleMatchingService, TitleMatchingService titleMatchingService,
TitleQueryService titleQueryService) : SearchControllerBase(proxyService, titleMatchingService) SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService)
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain) public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
@@ -165,54 +191,19 @@ namespace UmlautAdaptarr.Controllers
q => q.Key, q => q.Key,
q => string.Join(",", q.Value)); q => string.Join(",", q.Value));
string? searchKey = null; SearchItem? searchItem = null;
string? searchValue = null; string mediaType = "tv";
if (queryParameters.TryGetValue("tvdbid", out string? tvdbId)) if (queryParameters.TryGetValue("tvdbid", out string? tvdbId) && !string.IsNullOrEmpty(tvdbId))
{ {
searchKey = "tvdbid"; searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, tvdbId);
searchValue = tvdbId;
} }
else if (queryParameters.TryGetValue("q", out string? title)) else if (queryParameters.TryGetValue("q", out string? title) && !string.IsNullOrEmpty(title))
{ {
searchKey = "q"; searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title);
searchValue = title;
} }
// Perform the search if a valid search key was identified return await BaseSearch(options, domain, queryParameters, searchItem);
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);
} }
[HttpGet] [HttpGet]

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Text;
using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
namespace UmlautAdaptarr.Models namespace UmlautAdaptarr.Models
@@ -9,13 +8,13 @@ namespace UmlautAdaptarr.Models
public XDocument ContentDocument { get; private set; } public XDocument ContentDocument { get; private set; }
public string ContentType { get; set; } public string ContentType { get; set; }
public Encoding ContentEncoding { get; set; } public Encoding ContentEncoding { get; set; }
private HashSet<string> _uniqueItems; private readonly HashSet<string> _uniqueItems;
public AggregatedSearchResult(string contentType, Encoding contentEncoding) public AggregatedSearchResult(string contentType, Encoding contentEncoding)
{ {
ContentType = contentType; ContentType = contentType;
ContentEncoding = contentEncoding; ContentEncoding = contentEncoding;
_uniqueItems = new HashSet<string>(); _uniqueItems = [];
// Initialize ContentDocument with a basic RSS structure // Initialize ContentDocument with a basic RSS structure
ContentDocument = new XDocument(new XElement("rss", new XElement("channel"))); ContentDocument = new XDocument(new XElement("rss", new XElement("channel")));
@@ -35,10 +34,6 @@ namespace UmlautAdaptarr.Models
{ {
ContentDocument.Root.Element("channel").Add(item); ContentDocument.Root.Element("channel").Add(item);
} }
else
{
}
} }
} }
} }

View File

@@ -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<string>(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<string> GenerateTitleVariations(string? germanTitle)
{
if (germanTitle == null)
{
return [];
}
var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts();
// Start with base variations including handling umlauts
var baseVariations = new List<string>
{
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<string>
{
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();
}
}

View File

@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Routing; using UmlautAdaptarr.Routing;
using UmlautAdaptarr.Services; using UmlautAdaptarr.Services;
@@ -29,14 +30,17 @@ internal class Program
builder.Services.AddMemoryCache(options => builder.Services.AddMemoryCache(options =>
{ {
options.SizeLimit = 500; //options.SizeLimit = 20000;
}); });
builder.Services.AddSingleton<ILogger, Logger<TitleQueryService>>();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddScoped<TitleQueryService>(); builder.Services.AddHostedService<ArrSyncBackgroundService>();
builder.Services.AddScoped<ProxyService>(); builder.Services.AddSingleton<TitleApiService>(); // TODO rename
builder.Services.AddScoped<TitleMatchingService>(); builder.Services.AddSingleton<SearchItemLookupService>();
builder.Services.AddSingleton<TitleMatchingService>();
builder.Services.AddSingleton<SonarrClient>();
builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyService>();
var app = builder.Build(); var app = builder.Build();

View File

@@ -2,12 +2,10 @@
"profiles": { "profiles": {
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true,
"_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100",
"launchUrl": "/",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"applicationUrl": "http://localhost:5182" "applicationUrl": "http://localhost:5182"
}, },

View File

@@ -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<IEnumerable<SearchItem>> FetchAllItemsAsync();
public abstract Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
public abstract Task<SearchItem?> FetchItemByTitleAsync(string title);
}
}

View File

@@ -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<SonarrClient> logger) : ArrClientBase()
{
private readonly string _sonarrHost = configuration.GetValue<string>("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set");
private readonly string _sonarrApiKey = configuration.GetValue<string>("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set");
private readonly string _mediaType = "tv";
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = clientFactory.CreateClient();
var items = new List<SearchItem>();
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<List<dynamic>>(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<SearchItem?> 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<dynamic>(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<SearchItem?> 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<dynamic>(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;
}
}
}

View File

@@ -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<ArrSyncBackgroundService> 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<SearchItem> 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}.");
}
}
}
}
}

View File

@@ -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<string, HashSet<string>> 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<string>();
}
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;
}
}
}

View File

@@ -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 HttpClient _httpClient;
private readonly string _userAgent = configuration["Settings:UserAgent"] ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); private readonly string _userAgent;
// TODO: Add cache! private readonly ILogger<ProxyService> _logger;
private readonly IMemoryCache _cache;
private static readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestTimes = new();
public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<ProxyService> 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<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri) public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri)
{ {
var requestMessage = new HttpRequestMessage(); if (!HttpMethods.IsGet(context.Request.Method))
var requestMethod = context.Request.Method;
if (!HttpMethods.IsGet(requestMethod))
{ {
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) foreach (var header in context.Request.Headers)
{ {
if (header.Key == "User-Agent" && _userAgent.Length != 0) 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 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 if (responseMessage.IsSuccessStatusCode)
responseMessage.EnsureSuccessStatusCode(); {
_cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(5));
// Modify the response content if necessary }
/*var content = await responseMessage.Content.ReadAsStringAsync();
content = ReplaceCharacters(content);
responseMessage.Content = new StringContent(content);*/
return responseMessage; return responseMessage;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine(ex.ToString()); _logger.LogError(ex, $"Error proxying request: {UrlUtilities.RedactApiKey(targetUri)}. Error: {ex.Message}");
return null;
}
}
private string ReplaceCharacters(string input) // Create a response message indicating an internal server error
var errorResponse = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
{ {
return input.Replace("Ä", "AE"); Content = new StringContent($"An error occurred while processing your request: {ex.Message}")
};
return errorResponse;
}
} }
} }
} }

View File

@@ -0,0 +1,65 @@
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services
{
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient)
{
public async Task<SearchItem?> 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<SearchItem?> 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;
}
}
}

View File

@@ -0,0 +1,98 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace UmlautAdaptarr.Services
{
public class TitleApiService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<TitleApiService> 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<dynamic>(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<JObject>()
.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<dynamic>(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<string[]>();
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);
}
}
}

View File

@@ -4,46 +4,15 @@ using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
public partial class TitleMatchingService public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger)
{ {
public List<string> GenerateTitleVariations(string germanTitle) public string RenameTitlesInContent(string content, string[]? titleMatchVariations, string? expectedTitle)
{
var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts();
// Start with base variations including handling umlauts
var baseVariations = new List<string>
{
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<string>
{
withoutDash,
withSpaceInsteadOfDash,
withoutDash.ReplaceGermanUmlautsWithLatinEquivalents(),
withoutDash.RemoveGermanUmlautDots(),
withSpaceInsteadOfDash.ReplaceGermanUmlautsWithLatinEquivalents(),
withSpaceInsteadOfDash.RemoveGermanUmlautDots()
});
}
return baseVariations.Distinct().ToList();
}
public string RenameTitlesInContent(string content, List<string> germanTitleVariations, string expectedTitle)
{ {
var xDoc = XDocument.Parse(content); 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")) foreach (var item in xDoc.Descendants("item"))
{ {
var titleElement = item.Element("title"); var titleElement = item.Element("title");
@@ -52,9 +21,40 @@ namespace UmlautAdaptarr.Services
var originalTitle = titleElement.Value; var originalTitle = titleElement.Value;
var normalizedOriginalTitle = NormalizeTitle(originalTitle); var normalizedOriginalTitle = NormalizeTitle(originalTitle);
// Attempt to find a variation that matches the start of the original title if (useCacheService)
foreach (var variation in germanTitleVariations)
{ {
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 // Variation is already normalized at creation
var pattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]"); 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 // Find the first separator used in the original title for consistent replacement
var separator = FindFirstSeparator(originalTitle); var separator = FindFirstSeparator(originalTitle);
// Reconstruct the expected title using the original separator // 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 // Extract the suffix from the original title starting right after the matched variation length
var variationLength = variation.Length; var variationLength = variation.Length;
@@ -88,7 +88,10 @@ namespace UmlautAdaptarr.Services
var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix); var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix);
// Update the title element's value with the new title // 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 break; // Break after the first successful match and modification
} }
} }
@@ -102,7 +105,7 @@ namespace UmlautAdaptarr.Services
private static string NormalizeTitle(string title) private static string NormalizeTitle(string title)
{ {
title = title.RemoveAccentButKeepGermanUmlauts(); 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()); return WordSeperationCharRegex().Replace(title, " ".ToString());
} }
@@ -118,6 +121,33 @@ namespace UmlautAdaptarr.Services
return title.Replace(' ', separator); 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("[._ ]")] [GeneratedRegex("[._ ]")]
private static partial Regex WordSeperationCharRegex(); private static partial Regex WordSeperationCharRegex();

View File

@@ -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<TitleQueryService> _logger;
private readonly string _sonarrHost;
private readonly string _sonarrApiKey;
private readonly string _umlautAdaptarrApiHost;
public TitleQueryService(IMemoryCache memoryCache, ILogger<TitleQueryService> logger, IConfiguration configuration, IHttpClientFactory clientFactory)
{
_httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException();
_cache = memoryCache;
_logger = logger;
_sonarrHost = configuration.GetValue<string>("SONARR_HOST");
_sonarrApiKey = configuration.GetValue<string>("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<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 = (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<dynamic>(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<dynamic>(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<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 = (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);
}
}
}
}

View File

@@ -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<TitleQueryServiceLegacy> logger,
IConfiguration configuration,
IHttpClientFactory clientFactory,
SonarrClient sonarrClient)
{
private readonly HttpClient _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException();
private readonly string _sonarrHost = configuration.GetValue<string>("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set");
private readonly string _sonarrApiKey = configuration.GetValue<string>("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<dynamic>(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<dynamic>(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<dynamic>(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<dynamic>(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);
}
}
}
}

View File

@@ -10,7 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -40,5 +40,14 @@ namespace UmlautAdaptarr.Utilities
return BuildUrl(domain, queryParameters); return BuildUrl(domain, queryParameters);
} }
public static string RedactApiKey(string targetUri)
{
var apiKeyPattern = @"(apikey=)[^&]*";
var redactedUri = Regex.Replace(targetUri, apiKeyPattern, "$1[REDACTED]");
return redactedUri;
}
} }
} }

View File

@@ -9,7 +9,7 @@
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Http": {
"Url": "http://localhost:5005" "Url": "http://*:5005"
} }
} }
}, },

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
version: '3.8'
services:
umlautadaptarr:
build: .
environment:
SONARR_HOST: "http://localhost:8989"
SONARR_API_KEY: ""
ports:
- "5005:5005"