diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 564a196..3eadd80 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -155,7 +155,8 @@ public class SearchController(ProxyService proxyService, TitleMatchingService titleMatchingService, SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService) { - public readonly string[] AUDIO_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; + public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; + public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"]; [HttpGet] public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) @@ -180,8 +181,16 @@ public async Task GenericSearch([FromRoute] string options, [From { if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories)) { - // Search for audio - if (categories.Split(',').Any(category => AUDIO_CATEGORY_IDS.Contains(category))) + // look for (audio-)book + if (categories.Split(',').Any(category => READARR_CATEGORY_IDS.Contains(category))) + { + var mediaType = "book"; + // TODO rename function or use own + searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetReadarrTitleForExternalId()); + } + + // look for audio (lidarr) + if (searchItem == null && categories.Split(',').Any(category => LIDARR_CATEGORY_IDS.Contains(category))) { var mediaType = "audio"; searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetLidarrTitleForExternalId()); diff --git a/UmlautAdaptarr/Models/SearchItem.cs b/UmlautAdaptarr/Models/SearchItem.cs index 029c790..e54bbd9 100644 --- a/UmlautAdaptarr/Models/SearchItem.cs +++ b/UmlautAdaptarr/Models/SearchItem.cs @@ -37,58 +37,83 @@ public SearchItem( ExpectedAuthor = expectedAuthor; GermanTitle = germanTitle; MediaType = mediaType; - if (mediaType == "audio" && expectedAuthor != null) + if ((mediaType == "audio" || mediaType == "book") && expectedAuthor != null) { - // e.g. Die Ärzte - best of die Ärzte - if (expectedTitle.Contains(expectedAuthor)) - { - var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim(); + GenerateVariationsForBooksAndAudio(expectedTitle, mediaType, expectedAuthor); + } + else + { + GenerateVariationsForTV(germanTitle, mediaType, aliases); + } + } - if (titleWithoutAuthorName.Length < 2) - { - // TODO log warning that this album can't be searched for automatically - } - TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray(); - } - else + private void GenerateVariationsForTV(string? germanTitle, string mediaType, string[]? aliases) + { + TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray(); + + var allTitleVariations = new List(TitleSearchVariations); + + // If aliases are not null, generate variations for each and add them to the list + // TODO (not necessarily here) only use deu and eng alias + if (aliases != null) + { + foreach (var alias in aliases) { - TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray(); + allTitleVariations.AddRange(GenerateVariations(alias, mediaType)); } - TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray(); - AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray(); } - else + + AuthorMatchVariations = []; + + // if a german title ends with (DE) also add a search string that replaces (DE) with GERMAN + // also add a matching title without (DE) + if (germanTitle?.EndsWith("(DE)") ?? false) { - TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray(); + TitleSearchVariations = [.. TitleSearchVariations, + .. + GenerateVariations( + germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(), + mediaType)]; + + allTitleVariations.AddRange(GenerateVariations(germanTitle.Replace("(DE)", "").Trim(), mediaType)); + + } + + TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); + } - var allTitleVariations = new List(TitleSearchVariations); + private void GenerateVariationsForBooksAndAudio(string expectedTitle, string mediaType, string? expectedAuthor) + { + // e.g. Die Ärzte - best of die Ärzte + if (expectedTitle.Contains(expectedAuthor)) + { + var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim(); - // 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 (titleWithoutAuthorName.Length < 2) { - foreach (var alias in aliases) - { - allTitleVariations.AddRange(GenerateVariations(alias, mediaType)); - } + // TODO log warning that this album can't be searched for automatically } + TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray(); + } + else + { + TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray(); + } - AuthorMatchVariations = []; + TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray(); + AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray(); - // if a german title ends with (DE) also add a search string that replaces (DE) with GERMAN - // also add a matching title without (DE) - if (germanTitle?.EndsWith("(DE)") ?? false) + if (mediaType == "book") + { + if (expectedAuthor?.Contains(' ') ?? false) { - TitleSearchVariations = [.. TitleSearchVariations, .. - GenerateVariations( - germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(), - mediaType)]; - - allTitleVariations.AddRange(GenerateVariations(germanTitle.Replace("(DE)", "").Trim(), mediaType)); + var nameParts = expectedAuthor.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var lastName = nameParts.Last(); + var firstNames = nameParts.Take(nameParts.Length - 1); + var alternativeExpectedAuthor = $"{lastName}, {string.Join(" ", firstNames)}"; + AuthorMatchVariations = [.. AuthorMatchVariations, .. GenerateVariations(alternativeExpectedAuthor, mediaType)]; } - - TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); } } @@ -113,6 +138,11 @@ private IEnumerable GenerateVariations(string? title, string mediaType) cleanTitle.RemoveGermanUmlautDots() }; + if (mediaType == "book" || mediaType == "audio") + { + baseVariations.Add(cleanTitle.RemoveGermanUmlauts()); + } + // TODO: determine if this is really needed // Additional variations to accommodate titles with "-" if (cleanTitle.Contains('-')) @@ -142,6 +172,7 @@ private IEnumerable GenerateVariations(string? title, string mediaType) } else if (cleanTitle.StartsWith("A ")) { var cleanTitleWithoutArticle = title[2..].Trim(); + baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType)); } // Remove multiple spaces diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 9b871ea..5a0818e 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -51,6 +51,7 @@ private static void Main(string[] args) 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 index eb02df7..de953f7 100644 --- a/UmlautAdaptarr/Providers/LidarrClient.cs +++ b/UmlautAdaptarr/Providers/LidarrClient.cs @@ -42,6 +42,7 @@ public override async Task> FetchAllItemsAsync() var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}"; + // TODO add caching here // Disable cache for now as it can result in problems when adding new albums that aren't displayed on the artists page initially //if (cache.TryGetValue(lidarrAlbumUrl, out List? albums)) //{ @@ -49,7 +50,7 @@ public override async Task> FetchAllItemsAsync() //} //else //{ - logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); + logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl); var albums = JsonConvert.DeserializeObject>(albumApiResponse); //} @@ -108,6 +109,7 @@ public override async Task> FetchAllItemsAsync() try { // For now we have to fetch all items every time + // TODO if possible look at the author in search query and only update for author var searchItems = await FetchAllItemsAsync(); foreach (var searchItem in searchItems ?? []) { diff --git a/UmlautAdaptarr/Providers/ReadarrClient.cs b/UmlautAdaptarr/Providers/ReadarrClient.cs new file mode 100644 index 0000000..df60c88 --- /dev/null +++ b/UmlautAdaptarr/Providers/ReadarrClient.cs @@ -0,0 +1,172 @@ +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UmlautAdaptarr.Models; +using UmlautAdaptarr.Services; +using UmlautAdaptarr.Utilities; + +namespace UmlautAdaptarr.Providers +{ + public class ReadarrClient( + IHttpClientFactory clientFactory, + IConfiguration configuration, + CacheService cacheService, + IMemoryCache cache, + ILogger logger) : ArrClientBase() + { + private readonly string _readarrHost = configuration.GetValue("READARR_HOST") ?? throw new ArgumentException("READARR_HOST environment variable must be set"); + private readonly string _readarrApiKey = configuration.GetValue("READARR_API_KEY") ?? throw new ArgumentException("READARR_API_KEY environment variable must be set"); + private readonly string _mediaType = "book"; + + public override async Task> FetchAllItemsAsync() + { + var httpClient = clientFactory.CreateClient(); + var items = new List(); + + try + { + var readarrAuthorUrl = $"{_readarrHost}/api/v1/author?apikey={_readarrApiKey}"; + logger.LogInformation($"Fetching all authors from Readarr: {UrlUtilities.RedactApiKey(readarrAuthorUrl)}"); + var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl); + var authors = JsonConvert.DeserializeObject>(authorApiResponse); + + if (authors == null) + { + logger.LogError($"Readarr authors API request resulted in null"); + return items; + } + logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr."); + foreach (var author in authors) + { + var authorId = (int)author.id; + + var readarrBookUrl = $"{_readarrHost}/api/v1/book?authorId={authorId}&apikey={_readarrApiKey}"; + + // TODO add caching here + logger.LogInformation($"Fetching all books from authorId {authorId} from Readarr: {UrlUtilities.RedactApiKey(readarrBookUrl)}"); + var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl); + var books = JsonConvert.DeserializeObject>(bookApiResponse); + + if (books == null) + { + logger.LogWarning($"Readarr book API request for authorId {authorId} resulted in null"); + continue; + } + + logger.LogInformation($"Successfully fetched {books.Count} books for authorId {authorId} from Readarr."); + + // Cache books for 3 minutes + cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3)); + + foreach (var book in books) + { + var authorName = (string)author.authorName; + var bookTitle = GetSearchBookTitle((string)book.title, authorName); + + var expectedTitle = $"{bookTitle} {authorName}"; + + string[]? aliases = null; + + // Abuse externalId to set the search string Readarr uses + // TODO use own method or rename + var externalId = expectedTitle.GetReadarrTitleForExternalId(); + + var searchItem = new SearchItem + ( + arrId: authorId, + externalId: externalId, + title: bookTitle, + expectedTitle: bookTitle, + germanTitle: null, + aliases: aliases, + mediaType: _mediaType, + expectedAuthor: authorName + ); + + items.Add(searchItem); + } + } + + logger.LogInformation($"Finished fetching all items from Readarr"); + } + catch (Exception ex) + { + logger.LogError($"Error fetching all authors from Readarr: {ex.Message}"); + } + + return items; + } + + // Logic based on https://github.com/Readarr/Readarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs#L541 + public static string GetSearchBookTitle(string bookTitle, string authorName) + { + // Remove author prefix from title if present, e.g., "Tom Clancy: Ghost Protocol" + if (!string.IsNullOrEmpty(authorName) && bookTitle.StartsWith($"{authorName}:")) + { + bookTitle = bookTitle[(authorName.Length + 1)..].Trim(); + } + + // Remove subtitles or additional info enclosed in parentheses or following a colon, if any + int firstParenthesisIndex = bookTitle.IndexOf('('); + int firstColonIndex = bookTitle.IndexOf(':'); + + if (firstParenthesisIndex > -1) + { + int endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex); + if (endParenthesisIndex > -1 && bookTitle.Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1).Contains(' ')) + { + bookTitle = bookTitle[..firstParenthesisIndex].Trim(); + } + } + else if (firstColonIndex > -1) + { + bookTitle = bookTitle[..firstColonIndex].Trim(); + } + + return bookTitle; + } + + + public override async Task FetchItemByExternalIdAsync(string externalId) + { + try + { + // For now we have to fetch all items every time + // TODO if possible look at the author in search query and only update for author + var searchItems = await FetchAllItemsAsync(); + foreach (var searchItem in searchItems ?? []) + { + try + { + cacheService.CacheSearchItem(searchItem); + } + catch (Exception ex) + { + logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); + } + } + } + catch (Exception ex) + { + logger.LogError($"Error fetching single author from Readarr: {ex.Message}"); + } + + return null; + } + + public override async Task FetchItemByTitleAsync(string title) + { + try + { + // this should never be called at the moment + throw new NotImplementedException(); + } + catch (Exception ex) + { + logger.LogError($"Error fetching single author from Readarr: {ex.Message}"); + } + + return null; + } + } +} diff --git a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs index ccdd6cc..291d19f 100644 --- a/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs +++ b/UmlautAdaptarr/Services/ArrSyncBackgroundService.cs @@ -14,15 +14,18 @@ namespace UmlautAdaptarr.Services public class ArrSyncBackgroundService( SonarrClient sonarrClient, LidarrClient lidarrClient, + ReadarrClient readarrClient, CacheService cacheService, IConfiguration configuration, ILogger logger) : BackgroundService { private readonly bool _sonarrEnabled = configuration.GetValue("SONARR_ENABLED"); private readonly bool _lidarrEnabled = configuration.GetValue("LIDARR_ENABLED"); + private readonly bool _readarrEnabled = configuration.GetValue("READARR_ENABLED"); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("ArrSyncBackgroundService is starting."); + bool lastRunSuccess = true; while (!stoppingToken.IsCancellationRequested) { @@ -32,12 +35,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (syncSuccess) { + lastRunSuccess = true; 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); + if (lastRunSuccess) + { + lastRunSuccess = false; + logger.LogInformation("ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful."); + await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); + } + else + { + logger.LogInformation("ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row."); + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } } } @@ -49,13 +62,17 @@ private async Task FetchAndUpdateDataAsync() try { var success = true; + if (_readarrEnabled) + { + success = success && await FetchItemsFromReadarrAsync(); + } if (_sonarrEnabled) { - success = await FetchItemsFromSonarrAsync(); + success = success && await FetchItemsFromSonarrAsync(); } if (_lidarrEnabled) { - success = await FetchItemsFromLidarrAsync(); + success = success && await FetchItemsFromLidarrAsync(); } return success; } @@ -96,6 +113,21 @@ private async Task FetchItemsFromLidarrAsync() return false; } + private async Task FetchItemsFromReadarrAsync() + { + try + { + var items = await readarrClient.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 ?? []) diff --git a/UmlautAdaptarr/Services/CacheService.cs b/UmlautAdaptarr/Services/CacheService.cs index e6f7f43..778d1f6 100644 --- a/UmlautAdaptarr/Services/CacheService.cs +++ b/UmlautAdaptarr/Services/CacheService.cs @@ -10,6 +10,7 @@ namespace UmlautAdaptarr.Services public partial class CacheService(IMemoryCache cache) { private readonly Dictionary> VariationIndex = []; + private readonly Dictionary TitleVariations, string CacheKey)>> BookVariationIndex = []; private readonly Dictionary TitleVariations, string CacheKey)>> AudioVariationIndex = []; private const int VARIATION_LOOKUP_CACHE_LENGTH = 5; @@ -23,6 +24,11 @@ public void CacheSearchItem(SearchItem item) CacheAudioSearchItem(item, cacheKey); return; } + else if (item.MediaType == "book") + { + CacheBookSearchItem(item, cacheKey); + return; + } var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); @@ -61,13 +67,30 @@ public void CacheAudioSearchItem(SearchItem item, string cacheKey) } } + public void CacheBookSearchItem(SearchItem item, string cacheKey) + { + // Index author and title variations + foreach (var authorVariation in item.AuthorMatchVariations) + { + var normalizedAuthor = authorVariation.NormalizeForComparison(); + + if (!BookVariationIndex.ContainsKey(normalizedAuthor)) + { + BookVariationIndex[normalizedAuthor] = []; + } + + var titleVariations = item.TitleMatchVariations.Select(titleMatchVariation => titleMatchVariation.NormalizeForComparison()).ToHashSet(); + BookVariationIndex[normalizedAuthor].Add((titleVariations, cacheKey)); + } + } + public SearchItem? SearchItemByTitle(string mediaType, string title) { var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); - if (mediaType == "audio") + if (mediaType == "audio" || mediaType == "book") { - return FindBestMatchForAudio(normalizedTitle.NormalizeForComparison()); + return FindBestMatchForBooksAndAudio(normalizedTitle.NormalizeForComparison(), mediaType); } // Use the first few characters of the normalized title for cache prefix search @@ -126,9 +149,11 @@ public void CacheAudioSearchItem(SearchItem item, string cacheKey) return item; } - private SearchItem? FindBestMatchForAudio(string normalizedOriginalTitle) + private SearchItem? FindBestMatchForBooksAndAudio(string normalizedOriginalTitle, string mediaType) { - foreach (var authorEntry in AudioVariationIndex) + var index = mediaType == "audio" ? AudioVariationIndex : BookVariationIndex; + + foreach (var authorEntry in index) { if (normalizedOriginalTitle.Contains(authorEntry.Key)) { diff --git a/UmlautAdaptarr/Services/SearchItemLookupService.cs b/UmlautAdaptarr/Services/SearchItemLookupService.cs index 4b0eff5..49eb11d 100644 --- a/UmlautAdaptarr/Services/SearchItemLookupService.cs +++ b/UmlautAdaptarr/Services/SearchItemLookupService.cs @@ -3,10 +3,15 @@ namespace UmlautAdaptarr.Services { - public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient, LidarrClient lidarrClient, IConfiguration configuration) + public class SearchItemLookupService(CacheService cacheService, + SonarrClient sonarrClient, + ReadarrClient readarrClient, + LidarrClient lidarrClient, + IConfiguration configuration) { private readonly bool _sonarrEnabled = configuration.GetValue("SONARR_ENABLED"); private readonly bool _lidarrEnabled = configuration.GetValue("LIDARR_ENABLED"); + private readonly bool _readarrEnabled = configuration.GetValue("READARR_ENABLED"); public async Task GetOrFetchSearchItemByExternalId(string mediaType, string externalId) { // Attempt to get the item from the cache first @@ -32,6 +37,12 @@ public class SearchItemLookupService(CacheService cacheService, SonarrClient son fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId); } break; + case "book": + if (_readarrEnabled) + { + fetchedItem = await readarrClient.FetchItemByExternalIdAsync(externalId); + } + break; } // If an item is fetched, cache it @@ -64,6 +75,8 @@ public class SearchItemLookupService(CacheService cacheService, SonarrClient son break; case "audio": break; + case "book": + break; // TODO add cases for other sources as needed, such as Radarr, Lidarr, etc. } diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs index a501d9c..4f75027 100644 --- a/UmlautAdaptarr/Services/TitleApiService.cs +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Services { @@ -27,6 +28,7 @@ private async Task EnsureMinimumDelayAsync() var httpClient = clientFactory.CreateClient(); var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; + logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}"); var response = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponseData = JsonConvert.DeserializeObject(response); @@ -72,6 +74,7 @@ private async Task EnsureMinimumDelayAsync() var httpClient = clientFactory.CreateClient(); var tvdbCleanTitle = title.Replace("ß", "ss"); var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; + logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}"); var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponseData = JsonConvert.DeserializeObject(titleApiResponse); diff --git a/UmlautAdaptarr/Services/TitleMatchingService.cs b/UmlautAdaptarr/Services/TitleMatchingService.cs index ea06aef..0eab296 100644 --- a/UmlautAdaptarr/Services/TitleMatchingService.cs +++ b/UmlautAdaptarr/Services/TitleMatchingService.cs @@ -52,7 +52,10 @@ public string RenameTitlesInContent(string content, SearchItem? searchItem) FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!); break; case "audio": - FindAndReplaceForAudio(searchItem, titleElement, originalTitle!); + FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!); + break; + case "book": + FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!); break; default: throw new NotImplementedException(); @@ -63,7 +66,7 @@ public string RenameTitlesInContent(string content, SearchItem? searchItem) return xDoc.ToString(); } - public void FindAndReplaceForAudio(SearchItem searchItem, XElement? titleElement, string originalTitle) + public void FindAndReplaceForBooksAndAudio(SearchItem searchItem, XElement? titleElement, string originalTitle) { var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); @@ -83,7 +86,11 @@ public void FindAndReplaceForAudio(SearchItem searchItem, XElement? titleElement string suffix = originalTitle[matchEndPositionInOriginal..].TrimStart([' ', '-', '_', '.']).Trim(); // Concatenate the expected title with the remaining suffix - var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}-[{suffix}]"; + var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}"; + if (suffix.Length >= 3) + { + updatedTitle += $"-[{suffix}]"; + } // Update the title element titleElement.Value = updatedTitle; @@ -91,7 +98,7 @@ public void FindAndReplaceForAudio(SearchItem searchItem, XElement? titleElement } else { - logger.LogInformation("TitleMatchingService - No satisfactory fuzzy match found for both author and title."); + logger.LogDebug($"TitleMatchingService - No satisfactory fuzzy match found for both author and title for {originalTitle}."); } } diff --git a/UmlautAdaptarr/Utilities/Extensions.cs b/UmlautAdaptarr/Utilities/Extensions.cs index 8e58052..801fa3e 100644 --- a/UmlautAdaptarr/Utilities/Extensions.cs +++ b/UmlautAdaptarr/Utilities/Extensions.cs @@ -50,16 +50,34 @@ public static string RemoveAccentButKeepGermanUmlauts(this string text) public static string GetLidarrTitleForExternalId(this string text) { text = text.RemoveGermanUmlautDots() + .Replace("-", "") .GetCleanTitle() .ToLower(); - // Lidarr removes the, an and a + // Lidarr removes the, an and a at the beginning return TitlePrefixRegex() .Replace(text, "") .RemoveExtraWhitespaces() .Trim(); } + public static string GetReadarrTitleForExternalId(this string text) + { + text = text.ToLower(); + + // Readarr removes "the" at the beginning + if (text.StartsWith("the ")) + { + text = text[4..]; + } + + return text.RemoveGermanUmlautDots() + .Replace(".", " ") + .Replace("-", " ") + .Replace(":", " ") + .GetCleanTitle(); + } + public static string GetCleanTitle(this string text) { return text @@ -81,11 +99,11 @@ public static string RemoveSpecialCharacters(this string text, bool removeUmlaut { if (removeUmlauts) { - return NoSpecialCharactersRegex().Replace(text, ""); + return NoSpecialCharactersExceptHypenRegex().Replace(text, ""); } else { - return NoSpecialCharactersExceptUmlautsRegex().Replace(text, ""); + return NoSpecialCharactersExceptHyphenAndUmlautsRegex().Replace(text, ""); } } @@ -114,6 +132,18 @@ public static string RemoveGermanUmlautDots(this string text) .Replace("ß", "ss"); } + public static string RemoveGermanUmlauts(this string text) + { + return text + .Replace("ö", "") + .Replace("ü", "") + .Replace("ä", "") + .Replace("Ö", "") + .Replace("Ü", "") + .Replace("Ä", "") + .Replace("ß", ""); + } + public static string RemoveExtraWhitespaces(this string text) { return MultipleWhitespaceRegex().Replace(text, " "); @@ -126,11 +156,11 @@ public static bool HasUmlauts(this string text) return umlauts.Any(text.Contains); } - [GeneratedRegex("[^a-zA-Z0-9 ]+", RegexOptions.Compiled)] - private static partial Regex NoSpecialCharactersRegex(); + [GeneratedRegex("[^a-zA-Z0-9 -]+", RegexOptions.Compiled)] + private static partial Regex NoSpecialCharactersExceptHypenRegex(); - [GeneratedRegex("[^a-zA-Z0-9 öäüßÖÄÜ]+", RegexOptions.Compiled)] - private static partial Regex NoSpecialCharactersExceptUmlautsRegex(); + [GeneratedRegex("[^a-zA-Z0-9 -öäüßÖÄÜß]+", RegexOptions.Compiled)] + private static partial Regex NoSpecialCharactersExceptHyphenAndUmlautsRegex(); [GeneratedRegex(@"\s+")] private static partial Regex MultipleWhitespaceRegex(); diff --git a/UmlautAdaptarr/Utilities/UrlUtilities.cs b/UmlautAdaptarr/Utilities/UrlUtilities.cs index bdec013..7feab50 100644 --- a/UmlautAdaptarr/Utilities/UrlUtilities.cs +++ b/UmlautAdaptarr/Utilities/UrlUtilities.cs @@ -35,7 +35,7 @@ public static string BuildUrl(string domain, string tParameter, string? apiKey = if (!string.IsNullOrEmpty(apiKey)) { - queryParameters["apiKey"] = apiKey; + queryParameters["apikey"] = apiKey; } return BuildUrl(domain, queryParameters); diff --git a/UmlautAdaptarr/secrets_example.json b/UmlautAdaptarr/secrets_example.json index 39ffdf5..8f58df8 100644 --- a/UmlautAdaptarr/secrets_example.json +++ b/UmlautAdaptarr/secrets_example.json @@ -4,5 +4,8 @@ "SONARR_API_KEY": "", "LIDARR_ENABLED": false, "LIDARR_HOST": "http://localhost:8686", - "LIDARR_API_KEY": "" + "LIDARR_API_KEY": "", + "READARR_ENABLED": false, + "READARR_HOST": "http://localhost:8787", + "READARR_API_KEY": "" } \ No newline at end of file