Intermediate commit

This commit is contained in:
pcjones
2024-02-12 21:04:18 +01:00
parent 0071b0c080
commit 4ca89f8bdd
14 changed files with 624 additions and 135 deletions

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Newtonsoft.Json.Linq;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using UmlautAdaptarr.Models;
@@ -34,7 +36,7 @@ namespace UmlautAdaptarr.Controllers
// Rename titles in the single search content
if (!string.IsNullOrEmpty(initialSearchResult?.Content))
{
inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem?.TitleMatchVariations, searchItem?.ExpectedTitle);
inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem);
}
var additionalTextSearch = searchItem != null
@@ -76,7 +78,7 @@ namespace UmlautAdaptarr.Controllers
}
// Handle multiple search requests based on German title variations
var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem.TitleMatchVariations, expectedTitle);
var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem);
aggregatedResult.AggregateItems(inititalProcessedContent);
return Content(aggregatedResult.Content, aggregatedResult.ContentType, aggregatedResult.ContentEncoding);
@@ -109,17 +111,17 @@ namespace UmlautAdaptarr.Controllers
}
private string ProcessContent(string content, string[]? titleMatchVariations = null, string? expectedTitle = null)
private string ProcessContent(string content, SearchItem? searchItem)
{
return titleMatchingService.RenameTitlesInContent(content, titleMatchVariations, expectedTitle);
return titleMatchingService.RenameTitlesInContent(content, searchItem);
}
public async Task<AggregatedSearchResult> AggregateSearchResults(
string domain,
IDictionary<string, string> queryParameters,
IEnumerable<string> titleSearchVariations,
string[] titleMatchVariations,
string expectedTitle)
SearchItem? searchItem
)
{
string defaultContentType = "application/xml";
Encoding defaultEncoding = Encoding.UTF8;
@@ -143,7 +145,7 @@ namespace UmlautAdaptarr.Controllers
}
// Process and rename titles in the content
content = ProcessContent(content, titleMatchVariations, expectedTitle);
content = ProcessContent(content, searchItem);
// Aggregate the items into a single document
aggregatedResult.AggregateItems(content);
@@ -157,6 +159,8 @@ namespace UmlautAdaptarr.Controllers
TitleMatchingService titleMatchingService,
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService)
{
public readonly string[] AUDIO_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"];
[HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
{
@@ -169,10 +173,27 @@ namespace UmlautAdaptarr.Controllers
[HttpGet]
public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain)
{
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
return await BaseSearch(options, domain, queryParameters);
SearchItem? searchItem = null;
if (queryParameters.TryGetValue("q", out string? title) && !string.IsNullOrEmpty(title))
{
if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories))
{
// Search for audio
if (categories.Split(',').Any(category => AUDIO_CATEGORY_IDS.Contains(category)))
{
var mediaType = "audio";
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.ToLower());
}
}
}
return await BaseSearch(options, domain, queryParameters, searchItem);
}
[HttpGet]
@@ -192,7 +213,7 @@ namespace UmlautAdaptarr.Controllers
q => string.Join(",", q.Value));
SearchItem? searchItem = null;
string mediaType = "tv";
var mediaType = "tv";
if (queryParameters.TryGetValue("tvdbid", out string? tvdbId) && !string.IsNullOrEmpty(tvdbId))
{

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.RegularExpressions;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Models
@@ -8,24 +9,51 @@ namespace UmlautAdaptarr.Models
public int ArrId { get; set; }
public string ExternalId { get; set; }
public string Title { get; set; }
public bool HasGermanUmlaut => Title?.HasGermanUmlauts() ?? false;
public bool HasUmlaut => Title?.HasUmlauts() ?? false;
public string ExpectedTitle { get; set; }
public string? ExpectedAuthor { get; set; }
public string? GermanTitle { get; set; }
public string[] TitleSearchVariations { get; set; }
public string[] TitleMatchVariations { get; set; }
public string[] AuthorMatchVariations { 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)
public SearchItem(
int arrId,
string externalId,
string title,
string expectedTitle,
string? germanTitle,
string mediaType,
string[]? aliases,
string? expectedAuthor = null)
{
ArrId = arrId;
ExternalId = externalId;
Title = title;
ExpectedTitle = expectedTitle;
ExpectedAuthor = expectedAuthor;
GermanTitle = germanTitle;
TitleSearchVariations = GenerateTitleVariations(germanTitle).ToArray();
MediaType = mediaType;
if (mediaType == "audio" && expectedAuthor != null)
{
// e.g. Die Ärzte - best of die Ärzte
if (expectedTitle.Contains(expectedAuthor))
{
var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim();
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
}
else
{
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray();
}
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray();
}
else
{
TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray();
var allTitleVariations = new List<string>(TitleSearchVariations);
// If aliases are not null, generate variations for each and add them to the list
@@ -34,14 +62,15 @@ namespace UmlautAdaptarr.Models
{
foreach (var alias in aliases)
{
allTitleVariations.AddRange(GenerateTitleVariations(alias));
allTitleVariations.AddRange(GenerateVariations(alias, mediaType));
}
}
TitleMatchVariations = allTitleVariations.Distinct().ToArray();
}
}
private IEnumerable<string> GenerateTitleVariations(string? germanTitle)
private IEnumerable<string> GenerateVariations(string? germanTitle, string mediaType)
{
if (germanTitle == null)
{
@@ -76,13 +105,17 @@ namespace UmlautAdaptarr.Models
});
}
// If a german title starts with der/die/das also accept variations without it
if (mediaType != "audio" && cleanTitle.StartsWith("Der") || cleanTitle.StartsWith("Die") || cleanTitle.StartsWith("Das"))
{
var cleanTitleWithoutArticle = germanTitle[3..].Trim();
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
}
// Remove multiple spaces
var cleanedVariations = baseVariations.Select(variation => MultipleWhitespaceRegex().Replace(variation, " "));
var cleanedVariations = baseVariations.Select(variation => variation.RemoveExtraWhitespaces());
return cleanedVariations.Distinct();
}
[GeneratedRegex(@"\s+")]
private static partial Regex MultipleWhitespaceRegex();
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using System.Net;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Routing;
@@ -10,8 +11,6 @@ internal class Program
// TODO:
// add option to sort by nzb age
// TODO
// add delay between requests
var builder = WebApplication.CreateBuilder(args);
@@ -38,7 +37,7 @@ internal class Program
builder.Logging.AddFilter((category, level) =>
{
// Prevent logging of HTTP request and response if the category is HttpClient
if (category.Contains("System.Net.Http.HttpClient"))
if (category.Contains("System.Net.Http.HttpClient") || category.Contains("Microsoft.Extensions.Http.DefaultHttpClientFactory"))
{
return false;
}
@@ -47,10 +46,11 @@ internal class Program
builder.Services.AddControllers();
builder.Services.AddHostedService<ArrSyncBackgroundService>();
builder.Services.AddSingleton<TitleApiService>(); // TODO rename
builder.Services.AddSingleton<TitleApiService>();
builder.Services.AddSingleton<SearchItemLookupService>();
builder.Services.AddSingleton<TitleMatchingService>();
builder.Services.AddSingleton<SonarrClient>();
builder.Services.AddSingleton<LidarrClient>();
builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyService>();

View File

@@ -0,0 +1,200 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers
{
public class LidarrClient(
IHttpClientFactory clientFactory,
IConfiguration configuration,
TitleApiService titleService,
ILogger<LidarrClient> logger) : ArrClientBase()
{
private readonly string _lidarrHost = configuration.GetValue<string>("LIDARR_HOST") ?? throw new ArgumentException("LIDARR_HOST environment variable must be set");
private readonly string _lidarrApiKey = configuration.GetValue<string>("LIDARR_API_KEY") ?? throw new ArgumentException("LIDARR_API_KEY environment variable must be set");
private readonly string _mediaType = "audio";
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = clientFactory.CreateClient();
var items = new List<SearchItem>();
try
{
var lidarrArtistsUrl = $"{_lidarrHost}/api/v1/artist?apikey={_lidarrApiKey}";
logger.LogInformation($"Fetching all artists from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl);
var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse);
if (artists == null)
{
logger.LogError($"Lidarr artists API request resulted in null");
return items;
}
logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr.");
foreach (var artist in artists)
{
var artistId = (int)artist.id;
var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}";
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
if (albums == null)
{
logger.LogWarning($"Lidarr album API request for artistId {artistId} resulted in null");
continue;
}
logger.LogInformation($"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr.");
foreach (var album in albums)
{
var artistName = (string)album.artist.artistName;
var albumTitle = (string)album.title;
var expectedTitle = $"{artistName} {albumTitle}";
string[]? aliases = null;
// Abuse externalId to set the search string Lidarr uses
var externalId = expectedTitle.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().RemoveExtraWhitespaces().ToLower();
var searchItem = new SearchItem
(
arrId: artistId,
externalId: externalId,
title: albumTitle,
expectedTitle: albumTitle,
germanTitle: null,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: artistName
);
items.Add(searchItem);
}
}
logger.LogInformation($"Finished fetching all items from Lidarr");
}
catch (Exception ex)
{
logger.LogError($"Error fetching all artists from Lidarr: {ex.Message}");
}
return items;
}
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
{
var httpClient = clientFactory.CreateClient();
try
{
var lidarrUrl = $"{_lidarrHost}/api/v1/series?mbId={externalId}&includeSeasonImages=false&apikey={_lidarrApiKey}";
logger.LogInformation($"Fetching item by external ID from Lidarr: {UrlUtilities.RedactApiKey(lidarrUrl)}");
var response = await httpClient.GetStringAsync(lidarrUrl);
var artists = JsonConvert.DeserializeObject<dynamic>(response);
var artist = artists?[0];
if (artist != null)
{
var mbId = (string)artist.mbId;
if (mbId == null)
{
logger.LogWarning($"Lidarr Artist {artist.id} doesn't have a mbId.");
return null;
}
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, mbId);
throw new NotImplementedException();
var searchItem = new SearchItem
(
arrId: (int)artist.id,
externalId: mbId,
title: (string)artist.title,
expectedTitle: (string)artist.title,
germanTitle: germanTitle,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: "TODO"
); ;
logger.LogInformation($"Successfully fetched artist {searchItem} from Lidarr.");
return searchItem;
}
}
catch (Exception ex)
{
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
}
return null;
}
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
{
var httpClient = clientFactory.CreateClient();
try
{
(string? germanTitle, string? mbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
if (mbId == null)
{
return null;
}
var lidarrUrl = $"{_lidarrHost}/api/v1/series?mbId={mbId}&includeSeasonImages=false&apikey={_lidarrApiKey}";
var lidarrApiResponse = await httpClient.GetStringAsync(lidarrUrl);
var artists = JsonConvert.DeserializeObject<dynamic>(lidarrApiResponse);
if (artists == null)
{
logger.LogError($"Parsing Lidarr API response for MB ID {mbId} resulted in null");
return null;
}
else if (artists.Count == 0)
{
logger.LogWarning($"No results found for MB ID {mbId}");
return null;
}
var expectedTitle = (string)artists[0].title;
if (expectedTitle == null)
{
logger.LogError($"Lidarr Title for MB ID {mbId} is null");
return null;
}
throw new NotImplementedException();
var searchItem = new SearchItem
(
arrId: (int)artists[0].id,
externalId: mbId,
title: (string)artists[0].title,
expectedTitle: (string)artists[0].title,
germanTitle: germanTitle,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: "TODO"
);
logger.LogInformation($"Successfully fetched artist {searchItem} from Lidarr.");
return searchItem;
}
catch (Exception ex)
{
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
}
return null;
}
}
}

View File

@@ -1,6 +1,4 @@
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using System.Net.Http;
using Newtonsoft.Json;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;

View File

@@ -13,9 +13,13 @@ namespace UmlautAdaptarr.Services
{
public class ArrSyncBackgroundService(
SonarrClient sonarrClient,
LidarrClient lidarrClient,
CacheService cacheService,
IConfiguration configuration,
ILogger<ArrSyncBackgroundService> logger) : BackgroundService
{
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("ArrSyncBackgroundService is starting.");
@@ -23,43 +27,78 @@ namespace UmlautAdaptarr.Services
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("ArrSyncBackgroundService is running.");
await FetchAndUpdateDataAsync();
var syncSuccess = await FetchAndUpdateDataAsync();
logger.LogInformation("ArrSyncBackgroundService has completed an iteration.");
if (syncSuccess)
{
await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
}
else
{
logger.LogInformation("ArrSyncBackgroundService is sleeping for one hour only because not all syncs were successful.");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
logger.LogInformation("ArrSyncBackgroundService is stopping.");
}
private async Task FetchAndUpdateDataAsync()
private async Task<bool> FetchAndUpdateDataAsync()
{
try
{
await FetchItemsFromSonarrAsync();
var success = true;
if (_sonarrEnabled)
{
success = await FetchItemsFromSonarrAsync();
}
if (_lidarrEnabled)
{
success = await FetchItemsFromLidarrAsync();
}
return success;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while fetching items from the Arrs.");
}
return false;
}
private async Task FetchItemsFromSonarrAsync()
private async Task<bool> FetchItemsFromSonarrAsync()
{
try
{
var items = await sonarrClient.FetchAllItemsAsync();
UpdateSearchItems(items);
return items?.Any()?? false;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while updating search item from Sonarr.");
}
return false;
}
private void UpdateSearchItems(IEnumerable<SearchItem> searchItems)
private async Task<bool> FetchItemsFromLidarrAsync()
{
foreach (var searchItem in searchItems)
try
{
var items = await lidarrClient.FetchAllItemsAsync();
UpdateSearchItems(items);
return items?.Any() ?? false;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
}
return false;
}
private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems)
{
foreach (var searchItem in searchItems ?? [])
{
try
{

View File

@@ -1,20 +1,28 @@
using Microsoft.Extensions.Caching.Memory;
using System.Text.RegularExpressions;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services
{
public class CacheService(IMemoryCache cache)
public partial class CacheService(IMemoryCache cache)
{
private readonly Dictionary<string, HashSet<string>> VariationIndex = [];
private readonly Dictionary<string, List<SearchItem>> AudioFuzzyIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
public void CacheSearchItem(SearchItem item)
{
var prefix = item.MediaType;
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
if (item.MediaType == "audio")
{
CacheAudioSearchItem(item);
return;
}
var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower();
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
cache.Set($"{prefix}_title_{normalizedTitle}", item);
foreach (var variation in item.TitleMatchVariations)
@@ -33,6 +41,26 @@ namespace UmlautAdaptarr.Services
}
}
private void CacheAudioSearchItem(SearchItem item)
{
// Normalize and simplify the title and author for fuzzy matching
var key = NormalizeForFuzzyMatching(item.ExternalId);
if (!AudioFuzzyIndex.ContainsKey(key))
{
AudioFuzzyIndex[key] = new List<SearchItem>();
}
AudioFuzzyIndex[key].Add(item);
}
private string NormalizeForFuzzyMatching(string input)
{
// Normalize the input string by removing accents, converting to lower case, and removing non-alphanumeric characters
var normalized = input.RemoveAccentButKeepGermanUmlauts().RemoveSpecialCharacters().ToLower();
normalized = WhiteSpaceRegex().Replace(normalized, "");
return normalized;
}
public SearchItem? SearchItemByTitle(string mediaType, string title)
{
var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower();
@@ -90,5 +118,8 @@ namespace UmlautAdaptarr.Services
}
return item;
}
[GeneratedRegex("\\s")]
private static partial Regex WhiteSpaceRegex();
}
}

View File

@@ -3,8 +3,10 @@ using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services
{
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient)
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient, LidarrClient lidarrClient, IConfiguration configuration)
{
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId)
{
// Attempt to get the item from the cache first
@@ -19,9 +21,17 @@ namespace UmlautAdaptarr.Services
switch (mediaType)
{
case "tv":
if (_sonarrEnabled)
{
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId);
}
break;
case "audio":
if (_lidarrEnabled)
{
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId);
}
break;
// TODO Add cases for other sources like Radarr, Lidarr, etc.
}
// If an item is fetched, cache it
@@ -47,7 +57,10 @@ namespace UmlautAdaptarr.Services
switch (mediaType)
{
case "tv":
if (_sonarrEnabled)
{
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title);
}
break;
// TODO add cases for other sources as needed, such as Radarr, Lidarr, etc.
}

View File

@@ -1,17 +1,17 @@
using System.Text.RegularExpressions;
using System.Xml.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services
{
public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger)
{
public string RenameTitlesInContent(string content, string[]? titleMatchVariations, string? expectedTitle)
public string RenameTitlesInContent(string content, SearchItem? searchItem)
{
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;
bool useCacheService = searchItem == null;
foreach (var item in xDoc.Descendants("item"))
{
@@ -21,31 +21,143 @@ namespace UmlautAdaptarr.Services
var originalTitle = titleElement.Value;
var normalizedOriginalTitle = NormalizeTitle(originalTitle);
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, normalizedOriginalTitle);
if (searchItem != null)
if (useCacheService)
{
// If a SearchItem is found, use its ExpectedTitle and titleMatchVariations for renaming
expectedTitle = searchItem.ExpectedTitle;
titleMatchVariations = searchItem.TitleMatchVariations;
// Use CacheService to find a matching SearchItem by title
searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle);
}
else
if (searchItem == null)
{
// Skip processing this item if no matching SearchItem is found
continue;
}
switch (mediaType)
{
case "tv":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
break;
case "movie":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
break;
case "audio":
ReplaceForAudio(searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
break;
default:
throw new NotImplementedException();
}
}
}
return xDoc.ToString();
}
private string NormalizeString(string text)
{
return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower();
}
public void ReplaceForAudio(SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
{
var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, NormalizeString(normalizedOriginalTitle), originalTitle);
var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, NormalizeString(normalizedOriginalTitle), originalTitle);
if (authorMatch.Item1 && titleMatch.Item1)
{
int matchEndPositionInOriginal = Math.Max(authorMatch.Item3, titleMatch.Item3);
// Ensure we trim any leading delimiters from the suffix
string suffix = originalTitle.Substring(matchEndPositionInOriginal).TrimStart([' ', '-', '_']);
// Concatenate the expected title with the remaining suffix
var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}-{suffix}";
// Update the title element
titleElement.Value = updatedTitle;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{updatedTitle}'");
}
else
{
logger.LogInformation("TitleMatchingService - No satisfactory fuzzy match found for both author and title.");
}
}
private Tuple<bool, int, int> FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
{
bool found = false;
int bestStart = int.MaxValue;
int bestEndInOriginal = -1;
foreach (var variation in variations)
{
var normalizedVariation = NormalizeString(variation);
int startNormalized = normalizedOriginal.IndexOf(normalizedVariation);
if (startNormalized >= 0)
{
found = true;
// Map the start position from the normalized string back to the original string
int startOriginal = MapNormalizedIndexToOriginal(normalizedOriginal, originalTitle, startNormalized);
int endOriginal = MapNormalizedIndexToOriginal(normalizedOriginal, originalTitle, startNormalized + normalizedVariation.Length);
bestStart = Math.Min(bestStart, startOriginal);
bestEndInOriginal = Math.Max(bestEndInOriginal, endOriginal);
}
}
if (!found) return Tuple.Create(false, 0, 0);
return Tuple.Create(found, bestStart, bestEndInOriginal);
}
// Maps an index from the normalized string back to a corresponding index in the original string
private int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex)
{
// Count non-special characters up to the given index in the normalized string
int nonSpecialCharCount = 0;
for (int i = 0; i < normalizedIndex && i < normalizedOriginal.Length; i++)
{
if (char.IsLetterOrDigit(normalizedOriginal[i]))
{
nonSpecialCharCount++;
}
}
// Count non-special characters in the original title to find the corresponding index
int originalIndex = 0;
for (int i = 0; i < originalTitle.Length; i++)
{
if (char.IsLetterOrDigit(originalTitle[i]))
{
if (--nonSpecialCharCount < 0)
{
break;
}
}
originalIndex = i;
}
return originalIndex + 1; // +1 to move past the matched character or to the next character in the original title
}
// This method replaces the first variation that starts at the beginning of the release title
private static void FindAndReplaceForMoviesAndTV(ILogger<TitleMatchingService> logger, SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
{
var titleMatchVariations = searchItem.TitleMatchVariations;
var expectedTitle = searchItem.ExpectedTitle;
var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length);
// Attempt to find a variation that matches the start of the original title
foreach (var variation in variationsOrderedByLength)
@@ -105,11 +217,6 @@ namespace UmlautAdaptarr.Services
}
}
}
}
return xDoc.ToString();
}
private static string NormalizeTitle(string title)
{
@@ -126,7 +233,11 @@ namespace UmlautAdaptarr.Services
private static string ReconstructTitleWithSeparator(string title, char separator)
{
// Replace spaces with the original separator found in the title
if (separator != ' ')
{
return title;
}
return title.Replace(' ', separator);
}
@@ -153,6 +264,10 @@ namespace UmlautAdaptarr.Services
{
return "book";
}
else if (category.StartsWith("Audio"))
{
return "audio";
}
return null;
}
@@ -160,5 +275,6 @@ namespace UmlautAdaptarr.Services
[GeneratedRegex("[._ ]")]
private static partial Regex WordSeperationCharRegex();
}
}

View File

@@ -141,7 +141,7 @@ namespace UmlautAdaptarr.Services
germanTitle = titleApiResponseData.germanTitle;
hasGermanTitle = true;
var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false;
var hasGermanUmlaut = germanTitle?.HasUmlauts() ?? false;
var result = (hasGermanUmlaut, germanTitle, expectedTitle);
memoryCache.Set(cacheKey, result, new MemoryCacheEntryOptions

View File

@@ -1,9 +1,10 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace UmlautAdaptarr.Utilities
{
public static class Extensions
public static partial class Extensions
{
public static string GetQuery(this HttpContext context, string key)
{
@@ -46,11 +47,18 @@ namespace UmlautAdaptarr.Utilities
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
}
// TODO possibly replace GetCleanTitle with RemoveSpecialCharacters
public static string GetCleanTitle(this string text)
{
return text.Replace("(", "").Replace(")", "").Replace("?","").Replace(":", "").Replace("'", "");
}
public static string RemoveSpecialCharacters(this string text)
{
return SpecialCharactersRegex().Replace(text, "");
}
public static string ReplaceGermanUmlautsWithLatinEquivalents(this string text)
{
return text
@@ -75,11 +83,22 @@ namespace UmlautAdaptarr.Utilities
.Replace("ß", "ss");
}
public static bool HasGermanUmlauts(this string text)
public static string RemoveExtraWhitespaces(this string text)
{
return MultipleWhitespaceRegex().Replace(text, " ");
}
public static bool HasUmlauts(this string text)
{
if (text == null) return false;
var umlauts = new[] { 'ö', 'ä', 'ü', 'Ä', 'Ü', 'Ö', 'ß' };
return umlauts.Any(text.Contains);
}
[GeneratedRegex("[^a-zA-Z0-9 ]+", RegexOptions.Compiled)]
private static partial Regex SpecialCharactersRegex();
[GeneratedRegex(@"\s+")]
private static partial Regex MultipleWhitespaceRegex();
}
}

View File

@@ -0,0 +1,5 @@
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": []
}

View File

@@ -1,4 +1,8 @@
{
"SONARR_ENABLED": false,
"SONARR_HOST": "http://localhost:8989",
"SONARR_API_KEY": ""
"SONARR_API_KEY": "",
"LIDARR_ENABLED": false,
"LIDARR_HOST": "http://localhost:8686",
"LIDARR_API_KEY": ""
}

View File

@@ -6,7 +6,17 @@ services:
restart: unless-stopped
environment:
- TZ=Europe/Berlin
- SONARR_HOST="http://sonarr:8989"
- SONARR_ENABLED=false
- SONARR_HOST="http://localhost:8989"
- SONARR_API_KEY="API_KEY"
- RADARR_ENABLED=false
- RADARR_HOST="http://localhost:7878"
- RADARR_API_KEY="API_KEY"
- READARR_ENABLED=false
- READARR_HOST="http://localhost:8787"
- READARR_API_KEY="API_KEY"
- LIDARR_ENABLED=false
- LIDARR_HOST="http://localhost:8686"
- LIDARR_API_KEY="API_KEY"
ports:
- "5005:5005"