Intermediate commit
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using UmlautAdaptarr.Models;
|
||||
@@ -34,7 +36,7 @@ namespace UmlautAdaptarr.Controllers
|
||||
// Rename titles in the single search content
|
||||
if (!string.IsNullOrEmpty(initialSearchResult?.Content))
|
||||
{
|
||||
inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem?.TitleMatchVariations, searchItem?.ExpectedTitle);
|
||||
inititalProcessedContent = ProcessContent(initialSearchResult.Content, searchItem);
|
||||
}
|
||||
|
||||
var additionalTextSearch = searchItem != null
|
||||
@@ -76,7 +78,7 @@ namespace UmlautAdaptarr.Controllers
|
||||
}
|
||||
|
||||
// Handle multiple search requests based on German title variations
|
||||
var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem.TitleMatchVariations, expectedTitle);
|
||||
var aggregatedResult = await AggregateSearchResults(domain, queryParameters, titleSearchVariations, searchItem);
|
||||
aggregatedResult.AggregateItems(inititalProcessedContent);
|
||||
|
||||
return Content(aggregatedResult.Content, aggregatedResult.ContentType, aggregatedResult.ContentEncoding);
|
||||
@@ -109,17 +111,17 @@ namespace UmlautAdaptarr.Controllers
|
||||
}
|
||||
|
||||
|
||||
private string ProcessContent(string content, string[]? titleMatchVariations = null, string? expectedTitle = null)
|
||||
private string ProcessContent(string content, SearchItem? searchItem)
|
||||
{
|
||||
return titleMatchingService.RenameTitlesInContent(content, titleMatchVariations, expectedTitle);
|
||||
return titleMatchingService.RenameTitlesInContent(content, searchItem);
|
||||
}
|
||||
|
||||
public async Task<AggregatedSearchResult> AggregateSearchResults(
|
||||
string domain,
|
||||
IDictionary<string, string> queryParameters,
|
||||
IEnumerable<string> titleSearchVariations,
|
||||
string[] titleMatchVariations,
|
||||
string expectedTitle)
|
||||
SearchItem? searchItem
|
||||
)
|
||||
{
|
||||
string defaultContentType = "application/xml";
|
||||
Encoding defaultEncoding = Encoding.UTF8;
|
||||
@@ -143,7 +145,7 @@ namespace UmlautAdaptarr.Controllers
|
||||
}
|
||||
|
||||
// Process and rename titles in the content
|
||||
content = ProcessContent(content, titleMatchVariations, expectedTitle);
|
||||
content = ProcessContent(content, searchItem);
|
||||
|
||||
// Aggregate the items into a single document
|
||||
aggregatedResult.AggregateItems(content);
|
||||
@@ -157,6 +159,8 @@ namespace UmlautAdaptarr.Controllers
|
||||
TitleMatchingService titleMatchingService,
|
||||
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService)
|
||||
{
|
||||
public readonly string[] AUDIO_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"];
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
|
||||
{
|
||||
@@ -169,10 +173,27 @@ namespace UmlautAdaptarr.Controllers
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain)
|
||||
{
|
||||
|
||||
var queryParameters = HttpContext.Request.Query.ToDictionary(
|
||||
q => q.Key,
|
||||
q => string.Join(",", q.Value));
|
||||
return await BaseSearch(options, domain, queryParameters);
|
||||
|
||||
SearchItem? searchItem = null;
|
||||
|
||||
if (queryParameters.TryGetValue("q", out string? title) && !string.IsNullOrEmpty(title))
|
||||
{
|
||||
if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories))
|
||||
{
|
||||
// Search for audio
|
||||
if (categories.Split(',').Any(category => AUDIO_CATEGORY_IDS.Contains(category)))
|
||||
{
|
||||
var mediaType = "audio";
|
||||
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.ToLower());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await BaseSearch(options, domain, queryParameters, searchItem);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -192,7 +213,7 @@ namespace UmlautAdaptarr.Controllers
|
||||
q => string.Join(",", q.Value));
|
||||
|
||||
SearchItem? searchItem = null;
|
||||
string mediaType = "tv";
|
||||
var mediaType = "tv";
|
||||
|
||||
if (queryParameters.TryGetValue("tvdbid", out string? tvdbId) && !string.IsNullOrEmpty(tvdbId))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Text.RegularExpressions;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Models
|
||||
@@ -8,24 +9,51 @@ namespace UmlautAdaptarr.Models
|
||||
public int ArrId { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public bool HasGermanUmlaut => Title?.HasGermanUmlauts() ?? false;
|
||||
public bool HasUmlaut => Title?.HasUmlauts() ?? false;
|
||||
public string ExpectedTitle { get; set; }
|
||||
public string? ExpectedAuthor { get; set; }
|
||||
public string? GermanTitle { get; set; }
|
||||
public string[] TitleSearchVariations { get; set; }
|
||||
public string[] TitleMatchVariations { get; set; }
|
||||
public string[] AuthorMatchVariations { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
// TODO public MediaType instead of string
|
||||
|
||||
public SearchItem(int arrId, string externalId, string title, string expectedTitle, string? germanTitle, string mediaType, string[]? aliases)
|
||||
public SearchItem(
|
||||
int arrId,
|
||||
string externalId,
|
||||
string title,
|
||||
string expectedTitle,
|
||||
string? germanTitle,
|
||||
string mediaType,
|
||||
string[]? aliases,
|
||||
string? expectedAuthor = null)
|
||||
{
|
||||
ArrId = arrId;
|
||||
ExternalId = externalId;
|
||||
Title = title;
|
||||
ExpectedTitle = expectedTitle;
|
||||
ExpectedAuthor = expectedAuthor;
|
||||
GermanTitle = germanTitle;
|
||||
TitleSearchVariations = GenerateTitleVariations(germanTitle).ToArray();
|
||||
MediaType = mediaType;
|
||||
|
||||
if (mediaType == "audio" && expectedAuthor != null)
|
||||
{
|
||||
// e.g. Die Ärzte - best of die Ärzte
|
||||
if (expectedTitle.Contains(expectedAuthor))
|
||||
{
|
||||
var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim();
|
||||
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray();
|
||||
}
|
||||
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
|
||||
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray();
|
||||
var allTitleVariations = new List<string>(TitleSearchVariations);
|
||||
|
||||
// If aliases are not null, generate variations for each and add them to the list
|
||||
@@ -34,14 +62,15 @@ namespace UmlautAdaptarr.Models
|
||||
{
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
allTitleVariations.AddRange(GenerateTitleVariations(alias));
|
||||
allTitleVariations.AddRange(GenerateVariations(alias, mediaType));
|
||||
}
|
||||
}
|
||||
|
||||
TitleMatchVariations = allTitleVariations.Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> GenerateTitleVariations(string? germanTitle)
|
||||
private IEnumerable<string> GenerateVariations(string? germanTitle, string mediaType)
|
||||
{
|
||||
if (germanTitle == null)
|
||||
{
|
||||
@@ -76,13 +105,17 @@ namespace UmlautAdaptarr.Models
|
||||
});
|
||||
}
|
||||
|
||||
// If a german title starts with der/die/das also accept variations without it
|
||||
if (mediaType != "audio" && cleanTitle.StartsWith("Der") || cleanTitle.StartsWith("Die") || cleanTitle.StartsWith("Das"))
|
||||
{
|
||||
var cleanTitleWithoutArticle = germanTitle[3..].Trim();
|
||||
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
var cleanedVariations = baseVariations.Select(variation => MultipleWhitespaceRegex().Replace(variation, " "));
|
||||
var cleanedVariations = baseVariations.Select(variation => variation.RemoveExtraWhitespaces());
|
||||
|
||||
return cleanedVariations.Distinct();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex MultipleWhitespaceRegex();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net;
|
||||
using UmlautAdaptarr.Providers;
|
||||
using UmlautAdaptarr.Routing;
|
||||
@@ -10,8 +11,6 @@ internal class Program
|
||||
// TODO:
|
||||
// add option to sort by nzb age
|
||||
|
||||
// TODO
|
||||
// add delay between requests
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -38,7 +37,7 @@ internal class Program
|
||||
builder.Logging.AddFilter((category, level) =>
|
||||
{
|
||||
// Prevent logging of HTTP request and response if the category is HttpClient
|
||||
if (category.Contains("System.Net.Http.HttpClient"))
|
||||
if (category.Contains("System.Net.Http.HttpClient") || category.Contains("Microsoft.Extensions.Http.DefaultHttpClientFactory"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -47,10 +46,11 @@ internal class Program
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddHostedService<ArrSyncBackgroundService>();
|
||||
builder.Services.AddSingleton<TitleApiService>(); // TODO rename
|
||||
builder.Services.AddSingleton<TitleApiService>();
|
||||
builder.Services.AddSingleton<SearchItemLookupService>();
|
||||
builder.Services.AddSingleton<TitleMatchingService>();
|
||||
builder.Services.AddSingleton<SonarrClient>();
|
||||
builder.Services.AddSingleton<LidarrClient>();
|
||||
builder.Services.AddSingleton<CacheService>();
|
||||
builder.Services.AddSingleton<ProxyService>();
|
||||
|
||||
|
||||
200
UmlautAdaptarr/Providers/LidarrClient.cs
Normal file
200
UmlautAdaptarr/Providers/LidarrClient.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Services;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Providers
|
||||
{
|
||||
public class LidarrClient(
|
||||
IHttpClientFactory clientFactory,
|
||||
IConfiguration configuration,
|
||||
TitleApiService titleService,
|
||||
ILogger<LidarrClient> logger) : ArrClientBase()
|
||||
{
|
||||
private readonly string _lidarrHost = configuration.GetValue<string>("LIDARR_HOST") ?? throw new ArgumentException("LIDARR_HOST environment variable must be set");
|
||||
private readonly string _lidarrApiKey = configuration.GetValue<string>("LIDARR_API_KEY") ?? throw new ArgumentException("LIDARR_API_KEY environment variable must be set");
|
||||
private readonly string _mediaType = "audio";
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
var lidarrArtistsUrl = $"{_lidarrHost}/api/v1/artist?apikey={_lidarrApiKey}";
|
||||
logger.LogInformation($"Fetching all artists from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
|
||||
var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl);
|
||||
var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse);
|
||||
|
||||
if (artists == null)
|
||||
{
|
||||
logger.LogError($"Lidarr artists API request resulted in null");
|
||||
return items;
|
||||
}
|
||||
logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr.");
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
var artistId = (int)artist.id;
|
||||
|
||||
var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}";
|
||||
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
|
||||
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
|
||||
var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
|
||||
|
||||
if (albums == null)
|
||||
{
|
||||
logger.LogWarning($"Lidarr album API request for artistId {artistId} resulted in null");
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation($"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr.");
|
||||
|
||||
foreach (var album in albums)
|
||||
{
|
||||
var artistName = (string)album.artist.artistName;
|
||||
var albumTitle = (string)album.title;
|
||||
|
||||
var expectedTitle = $"{artistName} {albumTitle}";
|
||||
|
||||
string[]? aliases = null;
|
||||
|
||||
// Abuse externalId to set the search string Lidarr uses
|
||||
var externalId = expectedTitle.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().RemoveExtraWhitespaces().ToLower();
|
||||
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: artistId,
|
||||
externalId: externalId,
|
||||
title: albumTitle,
|
||||
expectedTitle: albumTitle,
|
||||
germanTitle: null,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType,
|
||||
expectedAuthor: artistName
|
||||
);
|
||||
|
||||
items.Add(searchItem);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation($"Finished fetching all items from Lidarr");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error fetching all artists from Lidarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var lidarrUrl = $"{_lidarrHost}/api/v1/series?mbId={externalId}&includeSeasonImages=false&apikey={_lidarrApiKey}";
|
||||
logger.LogInformation($"Fetching item by external ID from Lidarr: {UrlUtilities.RedactApiKey(lidarrUrl)}");
|
||||
var response = await httpClient.GetStringAsync(lidarrUrl);
|
||||
var artists = JsonConvert.DeserializeObject<dynamic>(response);
|
||||
var artist = artists?[0];
|
||||
|
||||
if (artist != null)
|
||||
{
|
||||
var mbId = (string)artist.mbId;
|
||||
if (mbId == null)
|
||||
{
|
||||
logger.LogWarning($"Lidarr Artist {artist.id} doesn't have a mbId.");
|
||||
return null;
|
||||
}
|
||||
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, mbId);
|
||||
|
||||
throw new NotImplementedException();
|
||||
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: (int)artist.id,
|
||||
externalId: mbId,
|
||||
title: (string)artist.title,
|
||||
expectedTitle: (string)artist.title,
|
||||
germanTitle: germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType,
|
||||
expectedAuthor: "TODO"
|
||||
); ;
|
||||
|
||||
logger.LogInformation($"Successfully fetched artist {searchItem} from Lidarr.");
|
||||
return searchItem;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
(string? germanTitle, string? mbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
|
||||
|
||||
if (mbId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lidarrUrl = $"{_lidarrHost}/api/v1/series?mbId={mbId}&includeSeasonImages=false&apikey={_lidarrApiKey}";
|
||||
var lidarrApiResponse = await httpClient.GetStringAsync(lidarrUrl);
|
||||
var artists = JsonConvert.DeserializeObject<dynamic>(lidarrApiResponse);
|
||||
|
||||
if (artists == null)
|
||||
{
|
||||
logger.LogError($"Parsing Lidarr API response for MB ID {mbId} resulted in null");
|
||||
return null;
|
||||
}
|
||||
else if (artists.Count == 0)
|
||||
{
|
||||
logger.LogWarning($"No results found for MB ID {mbId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var expectedTitle = (string)artists[0].title;
|
||||
if (expectedTitle == null)
|
||||
{
|
||||
logger.LogError($"Lidarr Title for MB ID {mbId} is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new NotImplementedException();
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: (int)artists[0].id,
|
||||
externalId: mbId,
|
||||
title: (string)artists[0].title,
|
||||
expectedTitle: (string)artists[0].title,
|
||||
germanTitle: germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType,
|
||||
expectedAuthor: "TODO"
|
||||
);
|
||||
|
||||
logger.LogInformation($"Successfully fetched artist {searchItem} from Lidarr.");
|
||||
return searchItem;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -13,9 +13,13 @@ namespace UmlautAdaptarr.Services
|
||||
{
|
||||
public class ArrSyncBackgroundService(
|
||||
SonarrClient sonarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
CacheService cacheService,
|
||||
IConfiguration configuration,
|
||||
ILogger<ArrSyncBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
|
||||
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("ArrSyncBackgroundService is starting.");
|
||||
@@ -23,43 +27,78 @@ namespace UmlautAdaptarr.Services
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("ArrSyncBackgroundService is running.");
|
||||
await FetchAndUpdateDataAsync();
|
||||
var syncSuccess = await FetchAndUpdateDataAsync();
|
||||
logger.LogInformation("ArrSyncBackgroundService has completed an iteration.");
|
||||
|
||||
if (syncSuccess)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("ArrSyncBackgroundService is sleeping for one hour only because not all syncs were successful.");
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("ArrSyncBackgroundService is stopping.");
|
||||
}
|
||||
|
||||
private async Task FetchAndUpdateDataAsync()
|
||||
private async Task<bool> FetchAndUpdateDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await FetchItemsFromSonarrAsync();
|
||||
var success = true;
|
||||
if (_sonarrEnabled)
|
||||
{
|
||||
success = await FetchItemsFromSonarrAsync();
|
||||
}
|
||||
if (_lidarrEnabled)
|
||||
{
|
||||
success = await FetchItemsFromLidarrAsync();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while fetching items from the Arrs.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task FetchItemsFromSonarrAsync()
|
||||
private async Task<bool> FetchItemsFromSonarrAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await sonarrClient.FetchAllItemsAsync();
|
||||
UpdateSearchItems(items);
|
||||
return items?.Any()?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while updating search item from Sonarr.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void UpdateSearchItems(IEnumerable<SearchItem> searchItems)
|
||||
private async Task<bool> FetchItemsFromLidarrAsync()
|
||||
{
|
||||
foreach (var searchItem in searchItems)
|
||||
try
|
||||
{
|
||||
var items = await lidarrClient.FetchAllItemsAsync();
|
||||
UpdateSearchItems(items);
|
||||
return items?.Any() ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems)
|
||||
{
|
||||
foreach (var searchItem in searchItems ?? [])
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Text.RegularExpressions;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Services
|
||||
{
|
||||
public class CacheService(IMemoryCache cache)
|
||||
public partial class CacheService(IMemoryCache cache)
|
||||
{
|
||||
private readonly Dictionary<string, HashSet<string>> VariationIndex = [];
|
||||
private readonly Dictionary<string, List<SearchItem>> AudioFuzzyIndex = [];
|
||||
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
|
||||
|
||||
public void CacheSearchItem(SearchItem item)
|
||||
{
|
||||
var prefix = item.MediaType;
|
||||
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
|
||||
if (item.MediaType == "audio")
|
||||
{
|
||||
CacheAudioSearchItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower();
|
||||
|
||||
cache.Set($"{prefix}_extid_{item.ExternalId}", item);
|
||||
cache.Set($"{prefix}_title_{normalizedTitle}", item);
|
||||
|
||||
foreach (var variation in item.TitleMatchVariations)
|
||||
@@ -33,6 +41,26 @@ namespace UmlautAdaptarr.Services
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheAudioSearchItem(SearchItem item)
|
||||
{
|
||||
// Normalize and simplify the title and author for fuzzy matching
|
||||
var key = NormalizeForFuzzyMatching(item.ExternalId);
|
||||
|
||||
if (!AudioFuzzyIndex.ContainsKey(key))
|
||||
{
|
||||
AudioFuzzyIndex[key] = new List<SearchItem>();
|
||||
}
|
||||
AudioFuzzyIndex[key].Add(item);
|
||||
}
|
||||
|
||||
private string NormalizeForFuzzyMatching(string input)
|
||||
{
|
||||
// Normalize the input string by removing accents, converting to lower case, and removing non-alphanumeric characters
|
||||
var normalized = input.RemoveAccentButKeepGermanUmlauts().RemoveSpecialCharacters().ToLower();
|
||||
normalized = WhiteSpaceRegex().Replace(normalized, "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public SearchItem? SearchItemByTitle(string mediaType, string title)
|
||||
{
|
||||
var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower();
|
||||
@@ -90,5 +118,8 @@ namespace UmlautAdaptarr.Services
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[GeneratedRegex("\\s")]
|
||||
private static partial Regex WhiteSpaceRegex();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ using UmlautAdaptarr.Providers;
|
||||
|
||||
namespace UmlautAdaptarr.Services
|
||||
{
|
||||
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient)
|
||||
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient, LidarrClient lidarrClient, IConfiguration configuration)
|
||||
{
|
||||
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
|
||||
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
|
||||
public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId)
|
||||
{
|
||||
// Attempt to get the item from the cache first
|
||||
@@ -19,9 +21,17 @@ namespace UmlautAdaptarr.Services
|
||||
switch (mediaType)
|
||||
{
|
||||
case "tv":
|
||||
if (_sonarrEnabled)
|
||||
{
|
||||
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId);
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
if (_lidarrEnabled)
|
||||
{
|
||||
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId);
|
||||
}
|
||||
break;
|
||||
// TODO Add cases for other sources like Radarr, Lidarr, etc.
|
||||
}
|
||||
|
||||
// If an item is fetched, cache it
|
||||
@@ -47,7 +57,10 @@ namespace UmlautAdaptarr.Services
|
||||
switch (mediaType)
|
||||
{
|
||||
case "tv":
|
||||
if (_sonarrEnabled)
|
||||
{
|
||||
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title);
|
||||
}
|
||||
break;
|
||||
// TODO add cases for other sources as needed, such as Radarr, Lidarr, etc.
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Services
|
||||
{
|
||||
public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger)
|
||||
{
|
||||
public string RenameTitlesInContent(string content, string[]? titleMatchVariations, string? expectedTitle)
|
||||
public string RenameTitlesInContent(string content, SearchItem? searchItem)
|
||||
{
|
||||
var xDoc = XDocument.Parse(content);
|
||||
|
||||
// If expectedTitle and titleMatchVariations are provided use them, if not use the CacheService to find matches.
|
||||
bool useCacheService = string.IsNullOrEmpty(expectedTitle) || titleMatchVariations?.Length == 0;
|
||||
bool useCacheService = searchItem == null;
|
||||
|
||||
foreach (var item in xDoc.Descendants("item"))
|
||||
{
|
||||
@@ -21,31 +21,143 @@ namespace UmlautAdaptarr.Services
|
||||
var originalTitle = titleElement.Value;
|
||||
var normalizedOriginalTitle = NormalizeTitle(originalTitle);
|
||||
|
||||
if (useCacheService)
|
||||
{
|
||||
var categoryElement = item.Element("category");
|
||||
var category = categoryElement?.Value;
|
||||
var mediaType = GetMediaTypeFromCategory(category);
|
||||
|
||||
if (mediaType == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use CacheService to find a matching SearchItem by title
|
||||
var searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle);
|
||||
if (searchItem != null)
|
||||
if (useCacheService)
|
||||
{
|
||||
// If a SearchItem is found, use its ExpectedTitle and titleMatchVariations for renaming
|
||||
expectedTitle = searchItem.ExpectedTitle;
|
||||
titleMatchVariations = searchItem.TitleMatchVariations;
|
||||
// Use CacheService to find a matching SearchItem by title
|
||||
searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle);
|
||||
}
|
||||
else
|
||||
|
||||
if (searchItem == null)
|
||||
{
|
||||
// Skip processing this item if no matching SearchItem is found
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mediaType)
|
||||
{
|
||||
case "tv":
|
||||
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
|
||||
break;
|
||||
case "movie":
|
||||
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
|
||||
break;
|
||||
case "audio":
|
||||
ReplaceForAudio(searchItem, titleElement, originalTitle, normalizedOriginalTitle!);
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return xDoc.ToString();
|
||||
}
|
||||
|
||||
private string NormalizeString(string text)
|
||||
{
|
||||
return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower();
|
||||
}
|
||||
|
||||
|
||||
public void ReplaceForAudio(SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
|
||||
{
|
||||
var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, NormalizeString(normalizedOriginalTitle), originalTitle);
|
||||
var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, NormalizeString(normalizedOriginalTitle), originalTitle);
|
||||
|
||||
if (authorMatch.Item1 && titleMatch.Item1)
|
||||
{
|
||||
int matchEndPositionInOriginal = Math.Max(authorMatch.Item3, titleMatch.Item3);
|
||||
|
||||
// Ensure we trim any leading delimiters from the suffix
|
||||
string suffix = originalTitle.Substring(matchEndPositionInOriginal).TrimStart([' ', '-', '_']);
|
||||
|
||||
// Concatenate the expected title with the remaining suffix
|
||||
var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}-{suffix}";
|
||||
|
||||
// Update the title element
|
||||
titleElement.Value = updatedTitle;
|
||||
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{updatedTitle}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("TitleMatchingService - No satisfactory fuzzy match found for both author and title.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Tuple<bool, int, int> FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
|
||||
{
|
||||
bool found = false;
|
||||
int bestStart = int.MaxValue;
|
||||
int bestEndInOriginal = -1;
|
||||
|
||||
foreach (var variation in variations)
|
||||
{
|
||||
var normalizedVariation = NormalizeString(variation);
|
||||
int startNormalized = normalizedOriginal.IndexOf(normalizedVariation);
|
||||
|
||||
if (startNormalized >= 0)
|
||||
{
|
||||
found = true;
|
||||
// Map the start position from the normalized string back to the original string
|
||||
int startOriginal = MapNormalizedIndexToOriginal(normalizedOriginal, originalTitle, startNormalized);
|
||||
int endOriginal = MapNormalizedIndexToOriginal(normalizedOriginal, originalTitle, startNormalized + normalizedVariation.Length);
|
||||
|
||||
bestStart = Math.Min(bestStart, startOriginal);
|
||||
bestEndInOriginal = Math.Max(bestEndInOriginal, endOriginal);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return Tuple.Create(false, 0, 0);
|
||||
return Tuple.Create(found, bestStart, bestEndInOriginal);
|
||||
}
|
||||
|
||||
// Maps an index from the normalized string back to a corresponding index in the original string
|
||||
private int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex)
|
||||
{
|
||||
// Count non-special characters up to the given index in the normalized string
|
||||
int nonSpecialCharCount = 0;
|
||||
for (int i = 0; i < normalizedIndex && i < normalizedOriginal.Length; i++)
|
||||
{
|
||||
if (char.IsLetterOrDigit(normalizedOriginal[i]))
|
||||
{
|
||||
nonSpecialCharCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count non-special characters in the original title to find the corresponding index
|
||||
int originalIndex = 0;
|
||||
for (int i = 0; i < originalTitle.Length; i++)
|
||||
{
|
||||
if (char.IsLetterOrDigit(originalTitle[i]))
|
||||
{
|
||||
if (--nonSpecialCharCount < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
originalIndex = i;
|
||||
}
|
||||
|
||||
return originalIndex + 1; // +1 to move past the matched character or to the next character in the original title
|
||||
}
|
||||
|
||||
|
||||
|
||||
// This method replaces the first variation that starts at the beginning of the release title
|
||||
private static void FindAndReplaceForMoviesAndTV(ILogger<TitleMatchingService> logger, SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
|
||||
{
|
||||
var titleMatchVariations = searchItem.TitleMatchVariations;
|
||||
var expectedTitle = searchItem.ExpectedTitle;
|
||||
var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length);
|
||||
// Attempt to find a variation that matches the start of the original title
|
||||
foreach (var variation in variationsOrderedByLength)
|
||||
@@ -105,11 +217,6 @@ namespace UmlautAdaptarr.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return xDoc.ToString();
|
||||
}
|
||||
|
||||
|
||||
private static string NormalizeTitle(string title)
|
||||
{
|
||||
@@ -126,7 +233,11 @@ namespace UmlautAdaptarr.Services
|
||||
|
||||
private static string ReconstructTitleWithSeparator(string title, char separator)
|
||||
{
|
||||
// Replace spaces with the original separator found in the title
|
||||
if (separator != ' ')
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
return title.Replace(' ', separator);
|
||||
}
|
||||
|
||||
@@ -153,6 +264,10 @@ namespace UmlautAdaptarr.Services
|
||||
{
|
||||
return "book";
|
||||
}
|
||||
else if (category.StartsWith("Audio"))
|
||||
{
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -160,5 +275,6 @@ namespace UmlautAdaptarr.Services
|
||||
|
||||
[GeneratedRegex("[._ ]")]
|
||||
private static partial Regex WordSeperationCharRegex();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
5
UmlautAdaptarr/libman.json
Normal file
5
UmlautAdaptarr/libman.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"defaultProvider": "cdnjs",
|
||||
"libraries": []
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user