28 Commits

Author SHA1 Message Date
pcjones
a67d5c2d1e Merge branch 'develop' 2024-02-29 17:20:23 +01:00
pcjones
d1d05f8264 Fix url ending with / not being recognized as valid 2024-02-29 17:17:55 +01:00
Jonas F
939b902be3 Update README.md 2024-02-26 21:33:45 +01:00
Jonas F
f56d071642 Update README.md 2024-02-26 21:26:15 +01:00
Jonas F
7513e7d227 Update README.md 2024-02-26 21:23:26 +01:00
Jonas F
7a791dab23 Update README.md 2024-02-26 21:23:00 +01:00
Jonas F
402a4deba3 Update README.md 2024-02-26 21:21:05 +01:00
Jonas F
d31508fef3 Update README.md 2024-02-26 21:20:33 +01:00
pcjones
1c329e886d Update README 2024-02-24 16:33:50 +01:00
pcjones
6932c4b2f8 Add hyphen title matching if title contains colon 2024-02-23 14:09:20 +01:00
pcjones
ff051569ca Fix separator being added twice 2024-02-23 14:08:56 +01:00
pcjones
dbac09bf36 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-23 12:25:51 +01:00
pcjones
0bde5d5d24 Fix sync process not finishing if there was an error 2024-02-23 12:25:42 +01:00
Jonas F
99af842fc6 Update README.md 2024-02-20 15:16:19 +01:00
pcjones
fbfbeadb3e set FORCE_TEXT_SEARCH_ORIGINAL_TITLE to true by default 2024-02-19 21:35:16 +01:00
pcjones
7cfae00511 Fix SearchItem lookup not working for newly added items in Readarr and Lidarr 2024-02-19 21:04:32 +01:00
pcjones
cac920ae88 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-19 14:20:27 +01:00
pcjones
bab60771a4 Merge branch 'develop' 2024-02-19 14:20:10 +01:00
pcjones
828faae486 Lower minimum delay between two requests to 1.5 seconds; no longer use delay if cache can be used 2024-02-19 14:19:57 +01:00
Jonas F
333a18ecd5 Update README.md 2024-02-19 14:07:47 +01:00
pcjones
a4a57d899a Merge branch 'develop' 2024-02-19 14:05:16 +01:00
pcjones
f804dd796f Add title without umlauts as base variation 2024-02-19 06:20:47 +01:00
pcjones
b2e4dbbda6 Made apiKey parameter lowercase 2024-02-19 05:08:24 +01:00
Jonas F
cd70997507 Update README.md 2024-02-18 21:20:39 +01:00
pcjones
b741239194 Lidarr optimizations 2024-02-14 23:59:53 +01:00
pcjones
96f8ff9332 Refactor TItleMatchingService 2024-02-14 21:00:24 +01:00
pcjones
e739affb39 Use TitleMatchVariations instead of TitleSearchVariations in SearchItemByTitle 2024-02-14 20:55:13 +01:00
pcjones
92bdf14618 Remove test variable 2024-02-14 20:40:13 +01:00
16 changed files with 552 additions and 122 deletions

View File

@@ -5,7 +5,7 @@
## Erste Testversion ## Erste Testversion
Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen! Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen!
Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr und Lidarr schon Auswirkungen (abgesehen vom Caching). Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr, Readarr und Lidarr schon Auswirkungen (abgesehen vom Caching).
Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden.
@@ -30,7 +30,7 @@ UmlautAdaptarr löst mehrere Probleme:
# Wie macht UmlautAdaptarr das? # Wie macht UmlautAdaptarr das?
UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten. UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten.
Am Ende werden die gefundenen Releases immer so umbenannt, das die Arrs sie einwandfrei erkennen. Am Ende werden die gefundenen Releases immer so umbenannt, dass die Arrs sie einwandfrei erkennen.
Einige Beispiele findet ihr unter Features. Einige Beispiele findet ihr unter Features.
@@ -38,15 +38,17 @@ Einige Beispiele findet ihr unter Features.
| Feature | Status | | Feature | Status |
|-------------------------------------------------------------------|---------------| |-------------------------------------------------------------------|---------------|
| Prowlarr Support | ✓| | Prowlarr & NZB Hydra Support | ✓|
| Sonarr Support | ✓ | | Sonarr Support | ✓ |
| Lidarr Support | ✓| | Lidarr Support | ✓|
| Readarr Support | ✓ |
| Releases mit deutschem Titel werden erkannt | ✓ | | Releases mit deutschem Titel werden erkannt | ✓ |
| Releases mit TVDB-Alias Titel werden erkannt | ✓ | | Releases mit TVDB-Alias Titel werden erkannt | ✓ |
| Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ | | Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ |
| Anfragen-Caching für 5 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Anfragen-Caching für 5 Minuten zur Reduzierung der API-Zugriffe | ✓ |
| Usenet (newznab) Support |✓|
| Torrent Support | Vorerst nicht geplant |
| Radarr Support | Geplant | | Radarr Support | Geplant |
| Readarr Support | Geplant |
| Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant |
| Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant |
| Wünsche? | Vorschläge? | | Wünsche? | Vorschläge? |
@@ -85,9 +87,13 @@ Sonarr erwartet immer den Englischen Namen, der hier natürlich nicht gegeben is
## Kontakt & Support ## Kontakt & Support
- Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst. - Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst.
- [Telegram](https://t.me/pc_jones) - [Telegram](https://t.me/pc_jones)
- Discord: pcjones1 - Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM)
- Reddit: /u/IreliaIsLife
## Spenden
Über eine Spende freue ich mich natürlich immer :D
PayPal: https://paypal.me/pcjones1
Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!
### Licenses & Metadata source ### Licenses & Metadata source
- TV Metadata source: https://thetvdb.com - TV Metadata source: https://thetvdb.com

View File

@@ -8,7 +8,8 @@ namespace UmlautAdaptarr.Controllers
{ {
public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase
{ {
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = false; // TODO evaluate if this should be set to true by default
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true;
private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options, protected async Task<IActionResult> BaseSearch(string options,
string domain, string domain,
@@ -52,7 +53,7 @@ namespace UmlautAdaptarr.Controllers
var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations); var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations);
string searchQuery = string.Empty; var searchQuery = string.Empty;
if (queryParameters.TryGetValue("q", out string? q)) if (queryParameters.TryGetValue("q", out string? q))
{ {
searchQuery = q ?? string.Empty; searchQuery = q ?? string.Empty;
@@ -155,7 +156,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"]; 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] [HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain) public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
@@ -180,11 +182,18 @@ namespace UmlautAdaptarr.Controllers
{ {
if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories)) if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories))
{ {
// Search for audio // look for (audio-)book
if (categories.Split(',').Any(category => AUDIO_CATEGORY_IDS.Contains(category))) if (categories.Split(',').Any(category => READARR_CATEGORY_IDS.Contains(category)))
{
var mediaType = "book";
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"; var mediaType = "audio";
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.ToLower()); searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetLidarrTitleForExternalId());
} }
} }
} }

View File

@@ -12,6 +12,7 @@ namespace UmlautAdaptarr.Models
public bool HasUmlaut => Title?.HasUmlauts() ?? false; public bool HasUmlaut => Title?.HasUmlauts() ?? false;
public string ExpectedTitle { get; set; } public string ExpectedTitle { get; set; }
public string? ExpectedAuthor { get; set; } public string? ExpectedAuthor { get; set; }
// TODO rename GermanTitle into Foreign or LocalTitle?
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; }
@@ -36,22 +37,40 @@ namespace UmlautAdaptarr.Models
ExpectedAuthor = expectedAuthor; ExpectedAuthor = expectedAuthor;
GermanTitle = germanTitle; GermanTitle = germanTitle;
MediaType = mediaType; MediaType = mediaType;
if (mediaType == "audio" && expectedAuthor != null) if ((mediaType == "audio" || mediaType == "book") && expectedAuthor != null)
{ {
// e.g. Die Ärzte - best of die Ärzte GenerateVariationsForBooksAndAudio(expectedTitle, mediaType, expectedAuthor);
if (expectedTitle.Contains(expectedAuthor))
{
var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim();
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
} }
else else
{ {
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray(); // if mediatype is movie/tv and the Expected Title ends with a year but the german title doesn't then append the year to the german title and to aliases
// example: https://thetvdb.com/series/385925-avatar-the-last-airbender -> german Title is without 2024
var yearAtEndOfTitleMatch = YearAtEndOfTitleRegex().Match(expectedTitle);
if (yearAtEndOfTitleMatch.Success)
{
string year = yearAtEndOfTitleMatch.Value[1..^1];
if (GermanTitle != null && !GermanTitle.Contains(year))
{
GermanTitle = $"{germanTitle} {year}";
} }
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray(); if (aliases != null)
{
for (int i = 0; i < aliases.Length; i++)
{
if (!aliases[i].Contains(year))
{
aliases[i] = $"{aliases[i]} {year}";
} }
else }
}
}
GenerateVariationsForTV(GermanTitle, mediaType, aliases);
}
}
private void GenerateVariationsForTV(string? germanTitle, string mediaType, string[]? aliases)
{ {
TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray(); TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray();
@@ -64,6 +83,12 @@ namespace UmlautAdaptarr.Models
foreach (var alias in aliases) foreach (var alias in aliases)
{ {
allTitleVariations.AddRange(GenerateVariations(alias, mediaType)); allTitleVariations.AddRange(GenerateVariations(alias, mediaType));
// If title contains ":" also match for "-"
if (alias.Contains(':'))
{
allTitleVariations.Add(alias.Replace(":", " -"));
}
} }
} }
@@ -73,7 +98,8 @@ namespace UmlautAdaptarr.Models
// also add a matching title without (DE) // also add a matching title without (DE)
if (germanTitle?.EndsWith("(DE)") ?? false) if (germanTitle?.EndsWith("(DE)") ?? false)
{ {
TitleSearchVariations = [.. TitleSearchVariations, .. TitleSearchVariations = [.. TitleSearchVariations,
..
GenerateVariations( GenerateVariations(
germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(), germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(),
mediaType)]; mediaType)];
@@ -82,17 +108,63 @@ namespace UmlautAdaptarr.Models
} }
// If title contains ":" also match for "-"
if (germanTitle?.Contains(':') ?? false)
{
allTitleVariations.Add(germanTitle.Replace(":", " -"));
}
TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray();
} }
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 (titleWithoutAuthorName.Length < 2)
{
// TODO log warning that this album can't be searched for automatically
}
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
}
else
{
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray();
}
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray();
if (mediaType == "book")
{
if (expectedAuthor?.Contains(' ') ?? false)
{
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)];
}
}
} }
private IEnumerable<string> GenerateVariations(string? germanTitle, string mediaType) private IEnumerable<string> GenerateVariations(string? title, string mediaType)
{ {
if (germanTitle == null) if (title == null)
{
return [];
}
var cleanTitle = title.GetCleanTitle();
if (cleanTitle?.Length == 0)
{ {
return []; return [];
} }
var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts().GetCleanTitle();
// Start with base variations including handling umlauts // Start with base variations including handling umlauts
var baseVariations = new List<string> var baseVariations = new List<string>
@@ -102,6 +174,11 @@ namespace UmlautAdaptarr.Models
cleanTitle.RemoveGermanUmlautDots() cleanTitle.RemoveGermanUmlautDots()
}; };
if (mediaType == "book" || mediaType == "audio")
{
baseVariations.Add(cleanTitle.RemoveGermanUmlauts());
}
// TODO: determine if this is really needed // TODO: determine if this is really needed
// Additional variations to accommodate titles with "-" // Additional variations to accommodate titles with "-"
if (cleanTitle.Contains('-')) if (cleanTitle.Contains('-'))
@@ -121,10 +198,16 @@ namespace UmlautAdaptarr.Models
}); });
} }
// If a german title starts with der/die/das also accept variations without it // If a title starts with der/die/das also accept variations without it
if (mediaType != "audio" && cleanTitle.StartsWith("Der") || cleanTitle.StartsWith("Die") || cleanTitle.StartsWith("Das")) // Same for english the, an, a
if (cleanTitle.StartsWith("Der ") || cleanTitle.StartsWith("Die ") || cleanTitle.StartsWith("Das ")
|| cleanTitle.StartsWith("The ") || cleanTitle.StartsWith("An "))
{ {
var cleanTitleWithoutArticle = germanTitle[3..].Trim(); var cleanTitleWithoutArticle = title[3..].Trim();
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
} else if (cleanTitle.StartsWith("A "))
{
var cleanTitleWithoutArticle = title[2..].Trim();
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType)); baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
} }
@@ -133,5 +216,8 @@ namespace UmlautAdaptarr.Models
return cleanedVariations.Distinct(); return cleanedVariations.Distinct();
} }
[GeneratedRegex(@"\(\d{4}\)$")]
private static partial Regex YearAtEndOfTitleRegex();
} }
} }

View File

@@ -51,6 +51,7 @@ internal class Program
builder.Services.AddSingleton<TitleMatchingService>(); builder.Services.AddSingleton<TitleMatchingService>();
builder.Services.AddSingleton<SonarrClient>(); builder.Services.AddSingleton<SonarrClient>();
builder.Services.AddSingleton<LidarrClient>(); builder.Services.AddSingleton<LidarrClient>();
builder.Services.AddSingleton<ReadarrClient>();
builder.Services.AddSingleton<CacheService>(); builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyService>(); builder.Services.AddSingleton<ProxyService>();

View File

@@ -42,16 +42,18 @@ namespace UmlautAdaptarr.Providers
var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}"; var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}";
if (cache.TryGetValue(lidarrAlbumUrl, out List<dynamic>? albums)) // 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
logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); //if (cache.TryGetValue(lidarrAlbumUrl, out List<dynamic>? albums))
} //{
else // logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
{ //}
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); //else
//{
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl); var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse); var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
} //}
if (albums == null) if (albums == null)
{ {
@@ -74,7 +76,7 @@ namespace UmlautAdaptarr.Providers
string[]? aliases = null; string[]? aliases = null;
// Abuse externalId to set the search string Lidarr uses // Abuse externalId to set the search string Lidarr uses
var externalId = expectedTitle.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().RemoveExtraWhitespaces().ToLower(); var externalId = expectedTitle.GetLidarrTitleForExternalId();
var searchItem = new SearchItem var searchItem = new SearchItem
( (
@@ -107,6 +109,7 @@ namespace UmlautAdaptarr.Providers
try try
{ {
// For now we have to fetch all items every time // 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(); var searchItems = await FetchAllItemsAsync();
foreach (var searchItem in searchItems ?? []) foreach (var searchItem in searchItems ?? [])
{ {

View File

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

View File

@@ -22,7 +22,6 @@ namespace UmlautAdaptarr.Providers
try try
{ {
var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}"; var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}";
logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl); var response = await httpClient.GetStringAsync(sonarrUrl);

View File

@@ -14,15 +14,18 @@ namespace UmlautAdaptarr.Services
public class ArrSyncBackgroundService( public class ArrSyncBackgroundService(
SonarrClient sonarrClient, SonarrClient sonarrClient,
LidarrClient lidarrClient, LidarrClient lidarrClient,
ReadarrClient readarrClient,
CacheService cacheService, CacheService cacheService,
IConfiguration configuration, IConfiguration configuration,
ILogger<ArrSyncBackgroundService> logger) : BackgroundService ILogger<ArrSyncBackgroundService> logger) : BackgroundService
{ {
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED"); private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED"); private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
private readonly bool _readarrEnabled = configuration.GetValue<bool>("READARR_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.");
bool lastRunSuccess = true;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -32,14 +35,24 @@ namespace UmlautAdaptarr.Services
if (syncSuccess) if (syncSuccess)
{ {
lastRunSuccess = true;
await Task.Delay(TimeSpan.FromHours(12), stoppingToken); await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
} }
else else
{ {
logger.LogInformation("ArrSyncBackgroundService is sleeping for one hour only because not all syncs were successful."); 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); await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
} }
} }
}
logger.LogInformation("ArrSyncBackgroundService is stopping."); logger.LogInformation("ArrSyncBackgroundService is stopping.");
} }
@@ -49,13 +62,20 @@ namespace UmlautAdaptarr.Services
try try
{ {
var success = true; var success = true;
if (_readarrEnabled)
{
var syncSuccess = await FetchItemsFromReadarrAsync();
success = success && syncSuccess;
}
if (_sonarrEnabled) if (_sonarrEnabled)
{ {
success = await FetchItemsFromSonarrAsync(); var syncSuccess = await FetchItemsFromSonarrAsync();
success = success && syncSuccess;
} }
if (_lidarrEnabled) if (_lidarrEnabled)
{ {
success = await FetchItemsFromLidarrAsync(); var syncSuccess = await FetchItemsFromLidarrAsync();
success = success && syncSuccess;
} }
return success; return success;
} }
@@ -96,6 +116,21 @@ namespace UmlautAdaptarr.Services
return false; return false;
} }
private async Task<bool> 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<SearchItem>? searchItems) private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems)
{ {
foreach (var searchItem in searchItems ?? []) foreach (var searchItem in searchItems ?? [])

View File

@@ -10,6 +10,7 @@ namespace UmlautAdaptarr.Services
public partial 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<(HashSet<string> TitleVariations, string CacheKey)>> BookVariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = []; private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5; private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
@@ -23,6 +24,11 @@ namespace UmlautAdaptarr.Services
CacheAudioSearchItem(item, cacheKey); CacheAudioSearchItem(item, cacheKey);
return; return;
} }
else if (item.MediaType == "book")
{
CacheBookSearchItem(item, cacheKey);
return;
}
var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower();
@@ -61,13 +67,30 @@ namespace UmlautAdaptarr.Services
} }
} }
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) public SearchItem? SearchItemByTitle(string mediaType, string title)
{ {
var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); 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 // Use the first few characters of the normalized title for cache prefix search
@@ -84,7 +107,7 @@ namespace UmlautAdaptarr.Services
continue; continue;
} }
// After finding a potential item, compare normalizedTitle with each German title variation // After finding a potential item, compare normalizedTitle with each German title variation
foreach (var variation in item?.TitleSearchVariations ?? []) foreach (var variation in item?.TitleMatchVariations ?? [])
{ {
var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower();
if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase))
@@ -126,9 +149,11 @@ namespace UmlautAdaptarr.Services
return item; 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)) if (normalizedOriginalTitle.Contains(authorEntry.Key))
{ {

View File

@@ -20,6 +20,20 @@ namespace UmlautAdaptarr.Services
_cache = cache; _cache = cache;
} }
private static async Task EnsureMinimumDelayAsync(string targetUri)
{
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < TimeSpan.FromMilliseconds(1500))
{
await Task.Delay(TimeSpan.FromMilliseconds(1500) - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
}
public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri) public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri)
{ {
if (!HttpMethods.IsGet(context.Request.Method)) if (!HttpMethods.IsGet(context.Request.Method))
@@ -27,18 +41,6 @@ namespace UmlautAdaptarr.Services
throw new ArgumentException("Only GET requests are supported", context.Request.Method); throw new ArgumentException("Only GET requests are supported", context.Request.Method);
} }
// Throttling mechanism
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < TimeSpan.FromSeconds(3))
{
await Task.Delay(TimeSpan.FromSeconds(3) - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
// Check cache // Check cache
if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse)) if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse))
{ {
@@ -46,6 +48,8 @@ namespace UmlautAdaptarr.Services
return cachedResponse!; return cachedResponse!;
} }
await EnsureMinimumDelayAsync(targetUri);
var requestMessage = new HttpRequestMessage var requestMessage = new HttpRequestMessage
{ {
RequestUri = new Uri(targetUri), RequestUri = new Uri(targetUri),

View File

@@ -3,10 +3,15 @@ using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services 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<bool>("SONARR_ENABLED"); private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED"); private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
private readonly bool _readarrEnabled = configuration.GetValue<bool>("READARR_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
@@ -30,6 +35,14 @@ namespace UmlautAdaptarr.Services
if (_lidarrEnabled) if (_lidarrEnabled)
{ {
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId); fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
}
break;
case "book":
if (_readarrEnabled)
{
await readarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
} }
break; break;
} }
@@ -64,6 +77,8 @@ namespace UmlautAdaptarr.Services
break; break;
case "audio": case "audio":
break; break;
case "book":
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,5 +1,6 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
@@ -12,13 +13,14 @@ namespace UmlautAdaptarr.Services
private async Task EnsureMinimumDelayAsync() private async Task EnsureMinimumDelayAsync()
{ {
var sinceLastRequest = DateTime.Now - lastRequestTime; var sinceLastRequest = DateTime.Now - lastRequestTime;
if (sinceLastRequest < TimeSpan.FromSeconds(2)) if (sinceLastRequest < TimeSpan.FromSeconds(1))
{ {
await Task.Delay(TimeSpan.FromSeconds(2) - sinceLastRequest); await Task.Delay(TimeSpan.FromSeconds(1) - sinceLastRequest);
} }
lastRequestTime = DateTime.Now; lastRequestTime = DateTime.Now;
} }
// TODO add cache, TODO add bulk request
public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId)
{ {
try try
@@ -27,6 +29,7 @@ namespace UmlautAdaptarr.Services
var httpClient = clientFactory.CreateClient(); var httpClient = clientFactory.CreateClient();
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}";
logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}");
var response = await httpClient.GetStringAsync(titleApiUrl); var response = await httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(response); var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(response);
@@ -72,6 +75,7 @@ namespace UmlautAdaptarr.Services
var httpClient = clientFactory.CreateClient(); var httpClient = clientFactory.CreateClient();
var tvdbCleanTitle = title.Replace("ß", "ss"); var tvdbCleanTitle = title.Replace("ß", "ss");
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}";
logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}");
var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse); var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse);

View File

@@ -20,7 +20,7 @@ namespace UmlautAdaptarr.Services
if (titleElement != null) if (titleElement != null)
{ {
var originalTitle = titleElement.Value; var originalTitle = titleElement.Value;
var normalizedOriginalTitle = NormalizeTitle(originalTitle); var cleanTitleSeperatedBySpace = ReplaceSeperatorsWithSpace(originalTitle.RemoveAccentButKeepGermanUmlauts());
var categoryElement = item.Element("category"); var categoryElement = item.Element("category");
var category = categoryElement?.Value; var category = categoryElement?.Value;
@@ -34,7 +34,7 @@ namespace UmlautAdaptarr.Services
if (useCacheService) if (useCacheService)
{ {
// Use CacheService to find a matching SearchItem by title // Use CacheService to find a matching SearchItem by title
searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle); searchItem = cacheService.SearchItemByTitle(mediaType, cleanTitleSeperatedBySpace);
} }
if (searchItem == null) if (searchItem == null)
@@ -46,13 +46,16 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!); FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break; break;
case "movie": case "movie":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!); FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break; break;
case "audio": case "audio":
FindAndReplaceForAudio(searchItem, titleElement, originalTitle!); FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!);
break;
case "book":
FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!);
break; break;
default: default:
throw new NotImplementedException(); throw new NotImplementedException();
@@ -63,18 +66,17 @@ namespace UmlautAdaptarr.Services
return xDoc.ToString(); 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 authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
if (authorMatch.Item1 && titleMatch.Item1) if (authorMatch.foundMatch && titleMatch.foundMatch)
{ {
int matchEndPositionInOriginal = Math.Max(authorMatch.Item3, titleMatch.Item3); int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, titleMatch.bestEndInOriginal);
var test = originalTitle[matchEndPositionInOriginal];
// Check and adjust for immediate following delimiter // Check and adjust for immediate following delimiter
char[] delimiters = new char[] { ' ', '-', '_', '.' }; char[] delimiters = [' ', '-', '_', '.'];
if (matchEndPositionInOriginal < originalTitle.Length && delimiters.Contains(originalTitle[matchEndPositionInOriginal])) if (matchEndPositionInOriginal < originalTitle.Length && delimiters.Contains(originalTitle[matchEndPositionInOriginal]))
{ {
matchEndPositionInOriginal++; // Skip the delimiter if it's immediately after the match matchEndPositionInOriginal++; // Skip the delimiter if it's immediately after the match
@@ -84,7 +86,11 @@ namespace UmlautAdaptarr.Services
string suffix = originalTitle[matchEndPositionInOriginal..].TrimStart([' ', '-', '_', '.']).Trim(); string suffix = originalTitle[matchEndPositionInOriginal..].TrimStart([' ', '-', '_', '.']).Trim();
// Concatenate the expected title with the remaining suffix // 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 // Update the title element
titleElement.Value = updatedTitle; titleElement.Value = updatedTitle;
@@ -92,12 +98,12 @@ namespace UmlautAdaptarr.Services
} }
else 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}.");
} }
} }
private Tuple<bool, int, int> FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle) private (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
{ {
bool found = false; bool found = false;
int bestStart = int.MaxValue; int bestStart = int.MaxValue;
@@ -120,8 +126,8 @@ namespace UmlautAdaptarr.Services
} }
} }
if (!found) return Tuple.Create(false, 0, 0); if (!found) return (false, 0, 0);
return Tuple.Create(found, bestStart, bestEndInOriginal); return (found, bestStart, bestEndInOriginal);
} }
// Maps an index from the normalized string back to a corresponding index in the original string // Maps an index from the normalized string back to a corresponding index in the original string
@@ -160,6 +166,7 @@ namespace UmlautAdaptarr.Services
var titleMatchVariations = searchItem.TitleMatchVariations; var titleMatchVariations = searchItem.TitleMatchVariations;
var expectedTitle = searchItem.ExpectedTitle; var expectedTitle = searchItem.ExpectedTitle;
var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length); var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length);
// Attempt to find a variation that matches the start of the original title // Attempt to find a variation that matches the start of the original title
foreach (var variation in variationsOrderedByLength) foreach (var variation in variationsOrderedByLength)
{ {
@@ -199,8 +206,8 @@ namespace UmlautAdaptarr.Services
} }
} }
// Clean up any leading separators from the suffix // Clean up any leading separator from the suffix
suffix = Regex.Replace(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 // 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 // can lead to problems with shows such as "dark" that have international dubs
@@ -214,7 +221,7 @@ namespace UmlautAdaptarr.Services
*/ */
// Construct the new title with the original suffix // Construct the new title with the original suffix
var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix); var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : suffix.StartsWith(separator) ? suffix : $"{separator}{suffix}");
// Update the title element's value with the new title // Update the title element's value with the new title
//titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})"; //titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})";
@@ -226,9 +233,8 @@ namespace UmlautAdaptarr.Services
} }
} }
private static string NormalizeTitle(string title) private static string ReplaceSeperatorsWithSpace(string title)
{ {
title = title.RemoveAccentButKeepGermanUmlauts();
// Replace all known separators with space for normalization // Replace all known separators with space for normalization
return WordSeperationCharRegex().Replace(title, " ".ToString()); return WordSeperationCharRegex().Replace(title, " ".ToString());
} }

View File

@@ -47,20 +47,64 @@ namespace UmlautAdaptarr.Utilities
return stringBuilder.ToString().Normalize(NormalizationForm.FormC); return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
} }
// TODO possibly replace GetCleanTitle with RemoveSpecialCharacters public static string GetLidarrTitleForExternalId(this string text)
{
text = text.RemoveGermanUmlautDots()
.Replace("-", "")
.GetCleanTitle()
.ToLower();
// 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) public static string GetCleanTitle(this string text)
{ {
return text.Replace("(", "").Replace(")", "").Replace("?","").Replace(":", "").Replace("'", ""); return text
.Replace(".", " ")
.Replace(":", " ")
.RemoveAccentButKeepGermanUmlauts()
.RemoveSpecialCharacters(removeUmlauts: false)
.RemoveExtraWhitespaces()
.Trim();
} }
public static string NormalizeForComparison(this string text) public static string NormalizeForComparison(this string text)
{ {
// TODO see if we can replace RemoveGermanUmlautDots() with RemoveSpecialCharacters(removeUmlauts: false);
return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower(); return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower();
} }
public static string RemoveSpecialCharacters(this string text) public static string RemoveSpecialCharacters(this string text, bool removeUmlauts = true)
{ {
return SpecialCharactersRegex().Replace(text, ""); if (removeUmlauts)
{
return NoSpecialCharactersExceptHypenRegex().Replace(text, "");
}
else
{
return NoSpecialCharactersExceptHyphenAndUmlautsRegex().Replace(text, "");
}
} }
@@ -88,6 +132,18 @@ namespace UmlautAdaptarr.Utilities
.Replace("ß", "ss"); .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) public static string RemoveExtraWhitespaces(this string text)
{ {
return MultipleWhitespaceRegex().Replace(text, " "); return MultipleWhitespaceRegex().Replace(text, " ");
@@ -100,10 +156,16 @@ namespace UmlautAdaptarr.Utilities
return umlauts.Any(text.Contains); return umlauts.Any(text.Contains);
} }
[GeneratedRegex("[^a-zA-Z0-9 ]+", RegexOptions.Compiled)] [GeneratedRegex("[^a-zA-Z0-9 -]+", RegexOptions.Compiled)]
private static partial Regex SpecialCharactersRegex(); private static partial Regex NoSpecialCharactersExceptHypenRegex();
[GeneratedRegex("[^a-zA-Z0-9 -öäüßÖÄÜß]+", RegexOptions.Compiled)]
private static partial Regex NoSpecialCharactersExceptHyphenAndUmlautsRegex();
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex MultipleWhitespaceRegex(); private static partial Regex MultipleWhitespaceRegex();
[GeneratedRegex(@"\b(the|an|a)\b", RegexOptions.IgnoreCase, "de-DE")]
private static partial Regex TitlePrefixRegex();
} }
} }

View File

@@ -12,7 +12,7 @@ namespace UmlautAdaptarr.Utilities
// RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich // RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich
// Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle // Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle
var regex = UrlMatchingRegex(); var regex = UrlMatchingRegex();
return regex.IsMatch(domain) && !domain.EndsWith("/"); return regex.IsMatch(domain);
} }
public static string BuildUrl(string domain, IDictionary<string, string> queryParameters) public static string BuildUrl(string domain, IDictionary<string, string> queryParameters)
@@ -35,7 +35,7 @@ namespace UmlautAdaptarr.Utilities
if (!string.IsNullOrEmpty(apiKey)) if (!string.IsNullOrEmpty(apiKey))
{ {
queryParameters["apiKey"] = apiKey; queryParameters["apikey"] = apiKey;
} }
return BuildUrl(domain, queryParameters); return BuildUrl(domain, queryParameters);

View File

@@ -4,5 +4,8 @@
"SONARR_API_KEY": "", "SONARR_API_KEY": "",
"LIDARR_ENABLED": false, "LIDARR_ENABLED": false,
"LIDARR_HOST": "http://localhost:8686", "LIDARR_HOST": "http://localhost:8686",
"LIDARR_API_KEY": "" "LIDARR_API_KEY": "",
"READARR_ENABLED": false,
"READARR_HOST": "http://localhost:8787",
"READARR_API_KEY": ""
} }