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;
using Microsoft.AspNetCore.Mvc.Formatters;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System.Linq;
using System.Text; using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
@@ -34,7 +36,7 @@ namespace UmlautAdaptarr.Controllers
// Rename titles in the single search content // Rename titles in the single search content
if (!string.IsNullOrEmpty(initialSearchResult?.Content)) if (!string.IsNullOrEmpty(initialSearchResult?.Content))
{ {
inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem?.TitleMatchVariations, searchItem?.ExpectedTitle); inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem);
} }
var additionalTextSearch = searchItem != null var additionalTextSearch = searchItem != null
@@ -76,7 +78,7 @@ namespace UmlautAdaptarr.Controllers
} }
// Handle multiple search requests based on German title variations // 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); aggregatedResult.AggregateItems(inititalProcessedContent);
return Content(aggregatedResult.Content, aggregatedResult.ContentType, aggregatedResult.ContentEncoding); 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( public async Task<AggregatedSearchResult> AggregateSearchResults(
string domain, string domain,
IDictionary<string, string> queryParameters, IDictionary<string, string> queryParameters,
IEnumerable<string> titleSearchVariations, IEnumerable<string> titleSearchVariations,
string[] titleMatchVariations, SearchItem? searchItem
string expectedTitle) )
{ {
string defaultContentType = "application/xml"; string defaultContentType = "application/xml";
Encoding defaultEncoding = Encoding.UTF8; Encoding defaultEncoding = Encoding.UTF8;
@@ -143,7 +145,7 @@ namespace UmlautAdaptarr.Controllers
} }
// Process and rename titles in the content // Process and rename titles in the content
content = ProcessContent(content, titleMatchVariations, expectedTitle); content = ProcessContent(content, searchItem);
// Aggregate the items into a single document // Aggregate the items into a single document
aggregatedResult.AggregateItems(content); aggregatedResult.AggregateItems(content);
@@ -157,6 +159,8 @@ namespace UmlautAdaptarr.Controllers
TitleMatchingService titleMatchingService, TitleMatchingService titleMatchingService,
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService) SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService)
{ {
public readonly string[] AUDIO_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"];
[HttpGet] [HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain) public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
{ {
@@ -169,10 +173,27 @@ namespace UmlautAdaptarr.Controllers
[HttpGet] [HttpGet]
public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain) public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain)
{ {
var queryParameters = HttpContext.Request.Query.ToDictionary(
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key, q => q.Key,
q => string.Join(",", q.Value)); 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] [HttpGet]
@@ -192,7 +213,7 @@ namespace UmlautAdaptarr.Controllers
q => string.Join(",", q.Value)); q => string.Join(",", q.Value));
SearchItem? searchItem = null; SearchItem? searchItem = null;
string mediaType = "tv"; var mediaType = "tv";
if (queryParameters.TryGetValue("tvdbid", out string? tvdbId) && !string.IsNullOrEmpty(tvdbId)) 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; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Models namespace UmlautAdaptarr.Models
@@ -8,40 +9,68 @@ namespace UmlautAdaptarr.Models
public int ArrId { get; set; } public int ArrId { get; set; }
public string ExternalId { get; set; } public string ExternalId { get; set; }
public string Title { 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 ExpectedTitle { get; set; }
public string? ExpectedAuthor { get; set; }
public string? GermanTitle { get; set; } public string? GermanTitle { get; set; }
public string[] TitleSearchVariations { get; set; } public string[] TitleSearchVariations { get; set; }
public string[] TitleMatchVariations { get; set; } public string[] TitleMatchVariations { get; set; }
public string[] AuthorMatchVariations { get; set; }
public string MediaType { get; set; } public string MediaType { get; set; }
// TODO public MediaType instead of string // 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; ArrId = arrId;
ExternalId = externalId; ExternalId = externalId;
Title = title; Title = title;
ExpectedTitle = expectedTitle; ExpectedTitle = expectedTitle;
ExpectedAuthor = expectedAuthor;
GermanTitle = germanTitle; GermanTitle = germanTitle;
TitleSearchVariations = GenerateTitleVariations(germanTitle).ToArray();
MediaType = mediaType; MediaType = mediaType;
if (mediaType == "audio" && expectedAuthor != null)
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) // e.g. Die Ärzte - best of die Ärzte
if (expectedTitle.Contains(expectedAuthor))
{ {
allTitleVariations.AddRange(GenerateTitleVariations(alias)); 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);
TitleMatchVariations = allTitleVariations.Distinct().ToArray(); // 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(GenerateVariations(alias, mediaType));
}
}
TitleMatchVariations = allTitleVariations.Distinct().ToArray();
}
} }
private IEnumerable<string> GenerateTitleVariations(string? germanTitle) private IEnumerable<string> GenerateVariations(string? germanTitle, string mediaType)
{ {
if (germanTitle == null) 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 // Remove multiple spaces
var cleanedVariations = baseVariations.Select(variation => MultipleWhitespaceRegex().Replace(variation, " ")); var cleanedVariations = baseVariations.Select(variation => variation.RemoveExtraWhitespaces());
return cleanedVariations.Distinct(); return cleanedVariations.Distinct();
} }
[GeneratedRegex(@"\s+")]
private static partial Regex MultipleWhitespaceRegex();
} }
} }

View File

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

View File

@@ -13,9 +13,13 @@ namespace UmlautAdaptarr.Services
{ {
public class ArrSyncBackgroundService( public class ArrSyncBackgroundService(
SonarrClient sonarrClient, SonarrClient sonarrClient,
LidarrClient lidarrClient,
CacheService cacheService, CacheService cacheService,
IConfiguration configuration,
ILogger<ArrSyncBackgroundService> logger) : BackgroundService 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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("ArrSyncBackgroundService is starting."); logger.LogInformation("ArrSyncBackgroundService is starting.");
@@ -23,43 +27,78 @@ namespace UmlautAdaptarr.Services
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
logger.LogInformation("ArrSyncBackgroundService is running."); logger.LogInformation("ArrSyncBackgroundService is running.");
await FetchAndUpdateDataAsync(); var syncSuccess = await FetchAndUpdateDataAsync();
logger.LogInformation("ArrSyncBackgroundService has completed an iteration."); logger.LogInformation("ArrSyncBackgroundService has completed an iteration.");
await Task.Delay(TimeSpan.FromHours(12), stoppingToken); 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."); logger.LogInformation("ArrSyncBackgroundService is stopping.");
} }
private async Task FetchAndUpdateDataAsync() private async Task<bool> FetchAndUpdateDataAsync()
{ {
try try
{ {
await FetchItemsFromSonarrAsync(); var success = true;
if (_sonarrEnabled)
{
success = await FetchItemsFromSonarrAsync();
}
if (_lidarrEnabled)
{
success = await FetchItemsFromLidarrAsync();
}
return success;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "An error occurred while fetching items from the Arrs."); logger.LogError(ex, "An error occurred while fetching items from the Arrs.");
} }
return false;
} }
private async Task FetchItemsFromSonarrAsync() private async Task<bool> FetchItemsFromSonarrAsync()
{ {
try try
{ {
var items = await sonarrClient.FetchAllItemsAsync(); var items = await sonarrClient.FetchAllItemsAsync();
UpdateSearchItems(items); UpdateSearchItems(items);
return items?.Any()?? false;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "An error occurred while updating search item from Sonarr."); 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 try
{ {

View File

@@ -1,20 +1,28 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using System.Text.RegularExpressions;
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services 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, HashSet<string>> VariationIndex = [];
private readonly Dictionary<string, List<SearchItem>> AudioFuzzyIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5; private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
public void CacheSearchItem(SearchItem item) public void CacheSearchItem(SearchItem item)
{ {
var prefix = item.MediaType; var prefix = item.MediaType;
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
if (item.MediaType == "audio")
{
CacheAudioSearchItem(item);
return;
}
var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower();
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
cache.Set($"{prefix}_title_{normalizedTitle}", item); cache.Set($"{prefix}_title_{normalizedTitle}", item);
foreach (var variation in item.TitleMatchVariations) 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) public SearchItem? SearchItemByTitle(string mediaType, string title)
{ {
var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower();
@@ -90,5 +118,8 @@ namespace UmlautAdaptarr.Services
} }
return item; return item;
} }
[GeneratedRegex("\\s")]
private static partial Regex WhiteSpaceRegex();
} }
} }

View File

@@ -3,8 +3,10 @@ using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services 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) public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId)
{ {
// Attempt to get the item from the cache first // Attempt to get the item from the cache first
@@ -19,9 +21,17 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); if (_sonarrEnabled)
{
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId);
}
break;
case "audio":
if (_lidarrEnabled)
{
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId);
}
break; break;
// TODO Add cases for other sources like Radarr, Lidarr, etc.
} }
// If an item is fetched, cache it // If an item is fetched, cache it
@@ -47,7 +57,10 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); if (_sonarrEnabled)
{
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title);
}
break; break;
// TODO add cases for other sources as needed, such as Radarr, Lidarr, etc. // 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.Text.RegularExpressions;
using System.Xml.Linq; using System.Xml.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger) 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); var xDoc = XDocument.Parse(content);
// If expectedTitle and titleMatchVariations are provided use them, if not use the CacheService to find matches. bool useCacheService = searchItem == null;
bool useCacheService = string.IsNullOrEmpty(expectedTitle) || titleMatchVariations?.Length == 0;
foreach (var item in xDoc.Descendants("item")) foreach (var item in xDoc.Descendants("item"))
{ {
@@ -21,88 +21,40 @@ namespace UmlautAdaptarr.Services
var originalTitle = titleElement.Value; var originalTitle = titleElement.Value;
var normalizedOriginalTitle = NormalizeTitle(originalTitle); var normalizedOriginalTitle = NormalizeTitle(originalTitle);
if (useCacheService) var categoryElement = item.Element("category");
{ var category = categoryElement?.Value;
var categoryElement = item.Element("category"); var mediaType = GetMediaTypeFromCategory(category);
var category = categoryElement?.Value;
var mediaType = GetMediaTypeFromCategory(category);
if (mediaType == null)
{
continue;
}
// Use CacheService to find a matching SearchItem by title if (mediaType == null)
var searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle); {
if (searchItem != null) continue;
{
// 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;
}
} }
var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length); if (useCacheService)
// Attempt to find a variation that matches the start of the original title
foreach (var variation in variationsOrderedByLength)
{ {
// Skip variations that are already the expectedTitle // Use CacheService to find a matching SearchItem by title
if (variation == expectedTitle) searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle);
{ }
continue;
}
// Variation is already normalized at creation if (searchItem == null)
var variationMatchPattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]"); {
// Skip processing this item if no matching SearchItem is found
continue;
}
// Check if the originalTitle starts with the variation (ignoring case and separators) switch (mediaType)
if (Regex.IsMatch(normalizedOriginalTitle, variationMatchPattern, RegexOptions.IgnoreCase)) {
{ case "tv":
// Workaround for the rare case of e.g. "Frieren: Beyond Journey's End" that also has the alias "Frieren" FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
if (expectedTitle!.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) break;
{ case "movie":
logger.LogWarning($"TitleMatchingService - Didn't rename: '{originalTitle}' because the expected title '{expectedTitle}' starts with the variation '{variation}'"); FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
continue; break;
} case "audio":
var originalTitleMatchPattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]"); ReplaceForAudio(searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
break;
// Find the first separator used in the original title for consistent replacement default:
var separator = FindFirstSeparator(originalTitle); throw new NotImplementedException();
// Reconstruct the expected title using the original separator
var newTitlePrefix = expectedTitle!.Replace(" ", separator.ToString());
// Extract the suffix from the original title starting right after the matched variation length
var variationLength = variation.Length;
var suffix = originalTitle[Math.Min(variationLength, originalTitle.Length)..];
// Clean up any leading separators from the suffix
suffix = Regex.Replace(suffix, "^[._ ]+", "");
// TODO EVALUTE! definitely make this optional - this adds GERMAN to the title is the title is german to make sure it's recognized as german
// can lead to problems with shows such as "dark" that have international dubs
/*
// Check if "german" is not in the original title, ignoring case
if (!Regex.IsMatch(originalTitle, "german", RegexOptions.IgnoreCase))
{
// Insert "GERMAN" after the newTitlePrefix
newTitlePrefix += separator + "GERMAN";
}
*/
// Construct the new title with the original suffix
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;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'");
break; // Break after the first successful match and modification
}
} }
} }
} }
@@ -110,6 +62,161 @@ namespace UmlautAdaptarr.Services
return xDoc.ToString(); 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)
{
// Skip variations that are already the expectedTitle
if (variation == expectedTitle)
{
continue;
}
// Variation is already normalized at creation
var variationMatchPattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]");
// Check if the originalTitle starts with the variation (ignoring case and separators)
if (Regex.IsMatch(normalizedOriginalTitle, variationMatchPattern, RegexOptions.IgnoreCase))
{
// Workaround for the rare case of e.g. "Frieren: Beyond Journey's End" that also has the alias "Frieren"
if (expectedTitle!.StartsWith(variation, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning($"TitleMatchingService - Didn't rename: '{originalTitle}' because the expected title '{expectedTitle}' starts with the variation '{variation}'");
continue;
}
var originalTitleMatchPattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]");
// 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());
// Extract the suffix from the original title starting right after the matched variation length
var variationLength = variation.Length;
var suffix = originalTitle[Math.Min(variationLength, originalTitle.Length)..];
// Clean up any leading separators from the suffix
suffix = Regex.Replace(suffix, "^[._ ]+", "");
// TODO EVALUTE! definitely make this optional - this adds GERMAN to the title is the title is german to make sure it's recognized as german
// can lead to problems with shows such as "dark" that have international dubs
/*
// Check if "german" is not in the original title, ignoring case
if (!Regex.IsMatch(originalTitle, "german", RegexOptions.IgnoreCase))
{
// Insert "GERMAN" after the newTitlePrefix
newTitlePrefix += separator + "GERMAN";
}
*/
// Construct the new title with the original suffix
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;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'");
break; // Break after the first successful match and modification
}
}
}
private static string NormalizeTitle(string title) private static string NormalizeTitle(string title)
{ {
@@ -126,7 +233,11 @@ namespace UmlautAdaptarr.Services
private static string ReconstructTitleWithSeparator(string title, char separator) 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); return title.Replace(' ', separator);
} }
@@ -153,6 +264,10 @@ namespace UmlautAdaptarr.Services
{ {
return "book"; return "book";
} }
else if (category.StartsWith("Audio"))
{
return "audio";
}
return null; return null;
} }
@@ -160,5 +275,6 @@ namespace UmlautAdaptarr.Services
[GeneratedRegex("[._ ]")] [GeneratedRegex("[._ ]")]
private static partial Regex WordSeperationCharRegex(); private static partial Regex WordSeperationCharRegex();
} }
} }

View File

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

View File

@@ -1,9 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace UmlautAdaptarr.Utilities namespace UmlautAdaptarr.Utilities
{ {
public static class Extensions public static partial class Extensions
{ {
public static string GetQuery(this HttpContext context, string key) public static string GetQuery(this HttpContext context, string key)
{ {
@@ -46,11 +47,18 @@ namespace UmlautAdaptarr.Utilities
return stringBuilder.ToString().Normalize(NormalizationForm.FormC); return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
} }
// TODO possibly replace GetCleanTitle with RemoveSpecialCharacters
public static string GetCleanTitle(this string text) public static string GetCleanTitle(this string text)
{ {
return text.Replace("(", "").Replace(")", "").Replace("?","").Replace(":", "").Replace("'", ""); 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) public static string ReplaceGermanUmlautsWithLatinEquivalents(this string text)
{ {
return text return text
@@ -75,11 +83,22 @@ namespace UmlautAdaptarr.Utilities
.Replace("ß", "ss"); .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; if (text == null) return false;
var umlauts = new[] { 'ö', 'ä', 'ü', 'Ä', 'Ü', 'Ö', 'ß' }; var umlauts = new[] { 'ö', 'ä', 'ü', 'Ä', 'Ü', 'Ö', 'ß' };
return umlauts.Any(text.Contains); 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_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 restart: unless-stopped
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- SONARR_HOST="http://sonarr:8989" - SONARR_ENABLED=false
- SONARR_HOST="http://localhost:8989"
- SONARR_API_KEY="API_KEY" - 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: ports:
- "5005:5005" - "5005:5005"