diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 6ca5ea3..b22dec2 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -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 AggregateSearchResults( string domain, IDictionary queryParameters, IEnumerable 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 MovieSearch([FromRoute] string options, [FromRoute] string domain) { @@ -169,10 +173,27 @@ namespace UmlautAdaptarr.Controllers [HttpGet] public async Task GenericSearch([FromRoute] string options, [FromRoute] string domain) { - var queryParameters = HttpContext.Request.Query.ToDictionary( + + 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)) { diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs index 7205d21..91ca98a 100644 --- a/UmlautAdaptarr/Models/SearchItem.cs +++ b/UmlautAdaptarr/Models/SearchItem.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.RegularExpressions; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Models @@ -8,40 +9,68 @@ 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; - - var allTitleVariations = new List(TitleSearchVariations); - - // If aliases are not null, generate variations for each and add them to the list - // TODO (not necessarily here) only use deu and eng alias - if (aliases != null) + if (mediaType == "audio" && expectedAuthor != 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(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 GenerateTitleVariations(string? germanTitle) + private IEnumerable 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(); } } \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 2206b47..9b871ea 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using System.Net; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Routing; @@ -10,10 +11,8 @@ internal class Program // TODO: // 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; @@ -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(); - builder.Services.AddSingleton(); // TODO rename + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/UmlautAdaptarr/Providers/LidarrClient.cs b/UmlautAdaptarr/Providers/LidarrClient.cs new file mode 100644 index 0000000..e846483 --- /dev/null +++ b/UmlautAdaptarr/Providers/LidarrClient.cs @@ -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 logger) : ArrClientBase() + { + private readonly string _lidarrHost = configuration.GetValue("LIDARR_HOST") ?? throw new ArgumentException("LIDARR_HOST environment variable must be set"); + private readonly string _lidarrApiKey = configuration.GetValue("LIDARR_API_KEY") ?? throw new ArgumentException("LIDARR_API_KEY environment variable must be set"); + private readonly string _mediaType = "audio"; + + public override async Task> FetchAllItemsAsync() + { + var httpClient = clientFactory.CreateClient(); + var items = new List(); + + 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>(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>(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 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(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 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(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; + } + } +} diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index b23dcba..dbd41a7 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -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; diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs index 5699a33..ccdd6cc 100644 --- a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -13,9 +13,13 @@ namespace UmlautAdaptarr.Services { public class ArrSyncBackgroundService( SonarrClient sonarrClient, + LidarrClient lidarrClient, CacheService cacheService, + IConfiguration configuration, ILogger logger) : BackgroundService { + private readonly bool _sonarrEnabled = configuration.GetValue("SONARR_ENABLED"); + private readonly bool _lidarrEnabled = configuration.GetValue("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."); - 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."); } - private async Task FetchAndUpdateDataAsync() + private async Task 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 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 searchItems) + private async Task 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? searchItems) + { + foreach (var searchItem in searchItems ?? []) { try { diff --git a/UmlautAdaptarr/Services/CacheService.cs b/UmlautAdaptarr/Services/CacheService.cs index b75de67..882f2e7 100644 --- a/UmlautAdaptarr/Services/CacheService.cs +++ b/UmlautAdaptarr/Services/CacheService.cs @@ -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> VariationIndex = []; + private readonly Dictionary> 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(); + } + 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(); } } diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index 64529d5..955b043 100644 --- a/UmlautAdaptarr/Services/SearchItemLookupService.cs +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -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("SONARR_ENABLED"); + private readonly bool _lidarrEnabled = configuration.GetValue("LIDARR_ENABLED"); public async Task 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": - fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); + 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": - fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); + if (_sonarrEnabled) + { + fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); + } break; // TODO add cases for other sources as needed, such as Radarr, Lidarr, etc. } diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index 5edc19f..c185d54 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -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 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,88 +21,40 @@ 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; - } + var categoryElement = item.Element("category"); + var category = categoryElement?.Value; + var mediaType = GetMediaTypeFromCategory(category); - // Use CacheService to find a matching SearchItem by title - var searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle); - 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; - } + if (mediaType == null) + { + continue; } - 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) + if (useCacheService) { - // Skip variations that are already the expectedTitle - if (variation == expectedTitle) - { - continue; - } + // Use CacheService to find a matching SearchItem by title + searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle); + } - // Variation is already normalized at creation - var variationMatchPattern = "^" + Regex.Escape(variation).Replace("\\ ", "[._ ]"); + if (searchItem == null) + { + // Skip processing this item if no matching SearchItem is found + continue; + } - // 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 - } + 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(); } } } @@ -110,6 +62,161 @@ namespace UmlautAdaptarr.Services 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 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 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) { @@ -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(); + } } diff --git a/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs b/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs index c606464..eba17d4 100644 --- a/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs +++ b/UmlautAdaptarr/Services/TitleQueryServiceLegacy.cs @@ -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 diff --git a/UmlautAdaptarr/Utilities/Extensions.cs b/UmlautAdaptarr/Utilities/Extensions.cs index 736e393..89e309b 100644 --- a/UmlautAdaptarr/Utilities/Extensions.cs +++ b/UmlautAdaptarr/Utilities/Extensions.cs @@ -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(); } } diff --git a/UmlautAdaptarr/libman.json b/UmlautAdaptarr/libman.json new file mode 100644 index 0000000..ceee271 --- /dev/null +++ b/UmlautAdaptarr/libman.json @@ -0,0 +1,5 @@ +{ + "version": "1.0", + "defaultProvider": "cdnjs", + "libraries": [] +} \ No newline at end of file diff --git a/UmlautAdaptarr/secrets_example.json b/UmlautAdaptarr/secrets_example.json index a652a69..39ffdf5 100644 --- a/UmlautAdaptarr/secrets_example.json +++ b/UmlautAdaptarr/secrets_example.json @@ -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": "" } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index aa0a130..2e35f20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"