Add Multi Instance Support , Serilog , little Hotfixes
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using UmlautAdaptarr.Interfaces;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Services;
|
||||
|
||||
namespace UmlautAdaptarr.Providers
|
||||
namespace UmlautAdaptarr.Providers;
|
||||
|
||||
public abstract class ArrClientBase : IArrApplication
|
||||
{
|
||||
public abstract class ArrClientBase()
|
||||
{
|
||||
public abstract Task<IEnumerable<SearchItem>> FetchAllItemsAsync();
|
||||
public abstract Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
|
||||
public abstract Task<SearchItem?> FetchItemByTitleAsync(string title);
|
||||
}
|
||||
}
|
||||
public string InstanceName;
|
||||
public abstract Task<IEnumerable<SearchItem>> FetchAllItemsAsync();
|
||||
public abstract Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
|
||||
public abstract Task<SearchItem?> FetchItemByTitleAsync(string title);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace UmlautAdaptarr.Providers
|
||||
{
|
||||
public static class ArrClientFactory
|
||||
{
|
||||
// TODO, still uses old IConfiguration
|
||||
// TODO not used yet
|
||||
public static IEnumerable<TClient> CreateClients<TClient>(
|
||||
Func<string, TClient> constructor, IConfiguration configuration, string configKey) where TClient : ArrClientBase
|
||||
{
|
||||
var hosts = configuration.GetValue<string>(configKey)?.Split(',') ?? throw new ArgumentException($"{configKey} environment variable must be set if the app is enabled");
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
yield return constructor(host.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,168 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Options.ArrOptions;
|
||||
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
|
||||
using UmlautAdaptarr.Services;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Providers
|
||||
namespace UmlautAdaptarr.Providers;
|
||||
|
||||
public class LidarrClient : ArrClientBase
|
||||
{
|
||||
public class LidarrClient(
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly CacheService _cacheService;
|
||||
private readonly IHttpClientFactory _clientFactory;
|
||||
private readonly ILogger<LidarrClient> _logger;
|
||||
private readonly string _mediaType = "audio";
|
||||
|
||||
public LidarrClient([ServiceKey] string instanceName,
|
||||
IHttpClientFactory clientFactory,
|
||||
CacheService cacheService,
|
||||
IMemoryCache cache,
|
||||
ILogger<LidarrClient> logger, IOptions<LidarrInstanceOptions> options) : ArrClientBase()
|
||||
IMemoryCache cache, IOptionsMonitor<LidarrInstanceOptions> options,
|
||||
ILogger<LidarrClient> logger)
|
||||
{
|
||||
public LidarrInstanceOptions LidarrOptions { get; } = options.Value;
|
||||
private readonly string _mediaType = "audio";
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var lidarrArtistsUrl = $"{LidarrOptions.Host}/api/v1/artist?apikey={LidarrOptions.ApiKey}";
|
||||
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 = $"{LidarrOptions.Host}/api/v1/album?artistId={artistId}&apikey={LidarrOptions.ApiKey}";
|
||||
|
||||
// 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<dynamic>? albums))
|
||||
//{
|
||||
// logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
|
||||
//}
|
||||
//else
|
||||
//{
|
||||
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
|
||||
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.");
|
||||
|
||||
// Cache albums for 3 minutes
|
||||
cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3));
|
||||
|
||||
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.GetLidarrTitleForExternalId();
|
||||
|
||||
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)
|
||||
{
|
||||
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 artist from Lidarr: {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 artist from Lidarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
_clientFactory = clientFactory;
|
||||
_cacheService = cacheService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
InstanceName = instanceName;
|
||||
Options = options.Get(InstanceName);
|
||||
_logger.LogInformation($"Init Lidarr ({InstanceName})");
|
||||
}
|
||||
}
|
||||
|
||||
public LidarrInstanceOptions Options { get; init; }
|
||||
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = _clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var lidarrArtistsUrl = $"{Options.Host}/api/v1/artist?apikey={Options.ApiKey}";
|
||||
_logger.LogInformation(
|
||||
$"Fetching all artists from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
|
||||
var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl);
|
||||
var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse);
|
||||
|
||||
if (artists == null)
|
||||
{
|
||||
_logger.LogError($"Lidarr ({InstanceName}) artists API request resulted in null");
|
||||
return items;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr ({InstanceName}).");
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
var artistId = (int)artist.id;
|
||||
|
||||
var lidarrAlbumUrl = $"{Options.Host}/api/v1/album?artistId={artistId}&apikey={Options.ApiKey}";
|
||||
|
||||
// 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<dynamic>? albums))
|
||||
//{
|
||||
// logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
|
||||
//}
|
||||
//else
|
||||
//{
|
||||
_logger.LogInformation(
|
||||
$"Fetching all albums from artistId {artistId} from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
|
||||
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
|
||||
var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
|
||||
//}
|
||||
|
||||
if (albums == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
$"Lidarr ({InstanceName}) album API request for artistId {artistId} resulted in null");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
$"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr ({InstanceName}).");
|
||||
|
||||
// Cache albums for 3 minutes
|
||||
_cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3));
|
||||
|
||||
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.GetLidarrTitleForExternalId();
|
||||
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
artistId,
|
||||
externalId,
|
||||
albumTitle,
|
||||
albumTitle,
|
||||
null,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType,
|
||||
expectedAuthor: artistName
|
||||
);
|
||||
|
||||
items.Add(searchItem);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Finished fetching all items from Lidarr ({InstanceName})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching all artists from Lidarr ({InstanceName}) : {ex.Message}");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
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} in Lidarr ({InstanceName}).");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}) : {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 artist from Lidarr ({InstanceName}): {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +1,186 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Options.ArrOptions;
|
||||
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
|
||||
using UmlautAdaptarr.Services;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Providers
|
||||
namespace UmlautAdaptarr.Providers;
|
||||
|
||||
public class ReadarrClient : ArrClientBase
|
||||
{
|
||||
public class ReadarrClient(
|
||||
IHttpClientFactory clientFactory,
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly CacheService _cacheService;
|
||||
private readonly IHttpClientFactory _clientFactory;
|
||||
private readonly ILogger<ReadarrClient> _logger;
|
||||
private readonly string _mediaType = "book";
|
||||
|
||||
public ReadarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory,
|
||||
CacheService cacheService,
|
||||
IMemoryCache cache,
|
||||
IOptions<ReadarrInstanceOptions> options,
|
||||
ILogger<ReadarrClient> logger) : ArrClientBase()
|
||||
IOptionsMonitor<ReadarrInstanceOptions> options,
|
||||
ILogger<ReadarrClient> logger)
|
||||
{
|
||||
|
||||
public ReadarrInstanceOptions ReadarrOptions { get; } = options.Value;
|
||||
private readonly string _mediaType = "book";
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var readarrAuthorUrl = $"{ReadarrOptions.Host}/api/v1/author?apikey={ReadarrOptions.ApiKey}";
|
||||
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 = $"{ReadarrOptions.Host}/api/v1/book?authorId={authorId}&apikey={ReadarrOptions.ApiKey}";
|
||||
|
||||
// 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;
|
||||
}
|
||||
_clientFactory = clientFactory;
|
||||
_cacheService = cacheService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
InstanceName = instanceName;
|
||||
Options = options.Get(InstanceName);
|
||||
_logger.LogInformation($"Init ReadarrClient ({InstanceName})");
|
||||
}
|
||||
}
|
||||
|
||||
public ReadarrInstanceOptions Options { get; init; }
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = _clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var readarrAuthorUrl = $"{Options.Host}/api/v1/author?apikey={Options.ApiKey}";
|
||||
_logger.LogInformation(
|
||||
$"Fetching all authors from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrAuthorUrl)}");
|
||||
var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl);
|
||||
var authors = JsonConvert.DeserializeObject<List<dynamic>>(authorApiResponse);
|
||||
|
||||
if (authors == null)
|
||||
{
|
||||
_logger.LogError($"Readarr ({InstanceName}) authors API request resulted in null");
|
||||
return items;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr ({InstanceName}).");
|
||||
foreach (var author in authors)
|
||||
{
|
||||
var authorId = (int)author.id;
|
||||
|
||||
var readarrBookUrl = $"{Options.Host}/api/v1/book?authorId={authorId}&apikey={Options.ApiKey}";
|
||||
|
||||
// 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 ({InstanceName}) book API request for authorId {authorId} resulted in null");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
$"Successfully fetched {books.Count} books for authorId {authorId} from Readarr ({InstanceName}) .");
|
||||
|
||||
// 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
|
||||
(
|
||||
authorId,
|
||||
externalId,
|
||||
bookTitle,
|
||||
bookTitle,
|
||||
null,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType,
|
||||
expectedAuthor: authorName
|
||||
);
|
||||
|
||||
items.Add(searchItem);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Finished fetching all items from Readarr ({InstanceName})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching all authors from Readarr ({InstanceName}): {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
|
||||
var firstParenthesisIndex = bookTitle.IndexOf('(');
|
||||
var firstColonIndex = bookTitle.IndexOf(':');
|
||||
|
||||
if (firstParenthesisIndex > -1)
|
||||
{
|
||||
var 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 ({InstanceName}) : {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 ({InstanceName}) : {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +1,192 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using UmlautAdaptarr.Models;
|
||||
using UmlautAdaptarr.Options.ArrOptions;
|
||||
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
|
||||
using UmlautAdaptarr.Services;
|
||||
using UmlautAdaptarr.Utilities;
|
||||
|
||||
namespace UmlautAdaptarr.Providers
|
||||
namespace UmlautAdaptarr.Providers;
|
||||
|
||||
public class SonarrClient : ArrClientBase
|
||||
{
|
||||
public class SonarrClient(
|
||||
private readonly IHttpClientFactory _clientFactory;
|
||||
private readonly ILogger<SonarrClient> _logger;
|
||||
|
||||
private readonly string _mediaType = "tv";
|
||||
private readonly TitleApiService _titleService;
|
||||
|
||||
|
||||
public SonarrClient([ServiceKey] string instanceName,
|
||||
IHttpClientFactory clientFactory,
|
||||
TitleApiService titleService,
|
||||
IOptions<SonarrInstanceOptions> options,
|
||||
ILogger<SonarrClient> logger) : ArrClientBase()
|
||||
IOptionsMonitor<SonarrInstanceOptions> options,
|
||||
ILogger<SonarrClient> logger)
|
||||
{
|
||||
public SonarrInstanceOptions SonarrOptions { get; } = options.Value;
|
||||
private readonly string _mediaType = "tv";
|
||||
_clientFactory = clientFactory;
|
||||
_titleService = titleService;
|
||||
_logger = logger;
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
InstanceName = instanceName;
|
||||
Options = options.Get(InstanceName);
|
||||
_logger.LogInformation($"Init SonarrClient ({InstanceName})");
|
||||
}
|
||||
|
||||
public SonarrInstanceOptions Options { get; init; }
|
||||
|
||||
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
|
||||
{
|
||||
var httpClient = _clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
var items = new List<SearchItem>();
|
||||
var sonarrUrl = $"{Options.Host}/api/v3/series?includeSeasonImages=false&apikey={Options.ApiKey}";
|
||||
_logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
|
||||
var response = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<List<dynamic>>(response);
|
||||
|
||||
try
|
||||
if (shows != null)
|
||||
{
|
||||
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
|
||||
logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
|
||||
var response = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<List<dynamic>>(response);
|
||||
|
||||
if (shows != null)
|
||||
{
|
||||
logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr.");
|
||||
foreach (var show in shows)
|
||||
{
|
||||
var tvdbId = (string)show.tvdbId;
|
||||
if (tvdbId == null)
|
||||
{
|
||||
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId.");
|
||||
continue;
|
||||
}
|
||||
|
||||
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: (int)show.id,
|
||||
externalId: tvdbId,
|
||||
title: (string)show.title,
|
||||
expectedTitle: (string)show.title,
|
||||
germanTitle: germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType
|
||||
);
|
||||
|
||||
items.Add(searchItem);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation($"Finished fetching all items from Sonarr");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error fetching all shows from Sonarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
|
||||
logger.LogInformation($"Fetching item by external ID from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
|
||||
var response = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<dynamic>(response);
|
||||
var show = shows?[0];
|
||||
|
||||
if (show != null)
|
||||
_logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName}).");
|
||||
foreach (var show in shows)
|
||||
{
|
||||
var tvdbId = (string)show.tvdbId;
|
||||
if (tvdbId == null)
|
||||
{
|
||||
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId.");
|
||||
return null;
|
||||
_logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
|
||||
continue;
|
||||
}
|
||||
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
|
||||
|
||||
|
||||
var (germanTitle, aliases) =
|
||||
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: (int)show.id,
|
||||
externalId: tvdbId,
|
||||
title: (string)show.title,
|
||||
expectedTitle: (string)show.title,
|
||||
germanTitle: germanTitle,
|
||||
(int)show.id,
|
||||
tvdbId,
|
||||
(string)show.title,
|
||||
(string)show.title,
|
||||
germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType
|
||||
);
|
||||
|
||||
logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
|
||||
return searchItem;
|
||||
items.Add(searchItem);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
_logger.LogInformation($"Finished fetching all items from Sonarr ({InstanceName})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching all shows from Sonarr ({InstanceName}) : {ex.Message}");
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
|
||||
return items;
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
|
||||
{
|
||||
var httpClient = _clientFactory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient();
|
||||
var sonarrUrl =
|
||||
$"{Options.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={Options.ApiKey}";
|
||||
_logger.LogInformation(
|
||||
$"Fetching item by external ID from Sonarr ({InstanceName}): {UrlUtilities.RedactApiKey(sonarrUrl)}");
|
||||
var response = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<dynamic>(response);
|
||||
var show = shows?[0];
|
||||
|
||||
try
|
||||
if (show != null)
|
||||
{
|
||||
(string? germanTitle, string? tvdbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
|
||||
|
||||
var tvdbId = (string)show.tvdbId;
|
||||
if (tvdbId == null)
|
||||
{
|
||||
_logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
|
||||
var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
|
||||
|
||||
if (shows == null)
|
||||
{
|
||||
logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null");
|
||||
return null;
|
||||
}
|
||||
else if (shows.Count == 0)
|
||||
{
|
||||
logger.LogWarning($"No results found for TVDB ID {tvdbId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var expectedTitle = (string)shows[0].title;
|
||||
if (expectedTitle == null)
|
||||
{
|
||||
logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null");
|
||||
return null;
|
||||
}
|
||||
var (germanTitle, aliases) =
|
||||
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
|
||||
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
arrId: (int)shows[0].id,
|
||||
externalId: tvdbId,
|
||||
title: (string)shows[0].title,
|
||||
expectedTitle: (string)shows[0].title,
|
||||
germanTitle: germanTitle,
|
||||
(int)show.id,
|
||||
tvdbId,
|
||||
(string)show.title,
|
||||
(string)show.title,
|
||||
germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType
|
||||
);
|
||||
|
||||
logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
|
||||
_logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
|
||||
return searchItem;
|
||||
}
|
||||
catch (Exception ex)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching single show from Sonarr ({InstanceName}): {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
|
||||
{
|
||||
var httpClient = _clientFactory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var (germanTitle, tvdbId, aliases) =
|
||||
await _titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
|
||||
|
||||
if (tvdbId == null) return null;
|
||||
|
||||
var sonarrUrl =
|
||||
$"{Options.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={Options.ApiKey}";
|
||||
var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl);
|
||||
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
|
||||
|
||||
if (shows == null)
|
||||
{
|
||||
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}");
|
||||
_logger.LogError($"Parsing Sonarr ({InstanceName}) API response for TVDB ID {tvdbId} resulted in null");
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning($"No results found for TVDB ID {tvdbId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var expectedTitle = (string)shows[0].title;
|
||||
if (expectedTitle == null)
|
||||
{
|
||||
_logger.LogError($"Sonarr ({InstanceName}) : Title for TVDB ID {tvdbId} is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
var searchItem = new SearchItem
|
||||
(
|
||||
(int)shows[0].id,
|
||||
tvdbId,
|
||||
(string)shows[0].title,
|
||||
(string)shows[0].title,
|
||||
germanTitle,
|
||||
aliases: aliases,
|
||||
mediaType: _mediaType
|
||||
);
|
||||
|
||||
_logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
|
||||
return searchItem;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Error fetching single show from Sonarr ({InstanceName}) : {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user