added support for Radarr

This commit is contained in:
Marius Nechifor
2024-11-13 22:37:00 +02:00
committed by GitHub
parent 906be45758
commit 513134fd65
29 changed files with 484 additions and 314 deletions

View File

@@ -0,0 +1,8 @@
namespace Common.Configuration;
public abstract record ArrConfig
{
public required bool Enabled { get; init; }
public required List<ArrInstance> Instances { get; init; }
}

View File

@@ -1,6 +1,6 @@
namespace Common.Configuration;
public sealed class SonarrInstance
public sealed class ArrInstance
{
public required Uri Url { get; set; }

View File

@@ -2,6 +2,8 @@
public sealed class QBitConfig
{
public const string SectionName = "qBittorrent";
public required Uri Url { get; set; }
public required string Username { get; set; }

View File

@@ -1,6 +0,0 @@
namespace Common.Configuration;
public sealed class QuartzConfig
{
public required string BlockedTorrentTrigger { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Common.Configuration;
public sealed record RadarrConfig : ArrConfig
{
public const string SectionName = "Radarr";
}

View File

@@ -1,6 +1,6 @@
namespace Common.Configuration;
public sealed class SonarrConfig
public sealed record SonarrConfig : ArrConfig
{
public required List<SonarrInstance> Instances { get; set; }
public const string SectionName = "Sonarr";
}

View File

@@ -0,0 +1,8 @@
namespace Common.Configuration;
public sealed class TriggersConfig
{
public const string SectionName = "Triggers";
public required string QueueCleaner { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace Domain.Arr.Enums;
public enum InstanceType
{
Sonarr,
Radarr,
Lidarr,
Readarr
}

View File

@@ -0,0 +1,7 @@
namespace Domain.Arr.Queue;
public record QueueListResponse
{
public required int TotalRecords { get; init; }
public required IReadOnlyList<QueueRecord> Records { get; init; }
}

View File

@@ -0,0 +1,14 @@
namespace Domain.Arr.Queue;
public record QueueRecord
{
public int SeriesId { get; init; }
public int MovieId { get; init; }
public required string Title { get; init; }
public string Status { get; init; }
public string TrackedDownloadStatus { get; init; }
public string TrackedDownloadState { get; init; }
public required string DownloadId { get; init; }
public required string Protocol { get; init; }
public required int Id { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Radarr;
public sealed record RadarrCommand
{
public required string Name { get; init; }
public required HashSet<int> MovieIds { get; init; }
}

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record CustomFormat(
int Id,
string Name
);

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record Language(
int Id,
string Name
);

View File

@@ -1,10 +0,0 @@
namespace Domain.Sonarr.Queue;
public record QueueListResponse(
int Page,
int PageSize,
string SortKey,
string SortDirection,
int TotalRecords,
IReadOnlyList<Record> Records
);

View File

@@ -1,28 +0,0 @@
namespace Domain.Sonarr.Queue;
public record Record(
int SeriesId,
int EpisodeId,
int SeasonNumber,
IReadOnlyList<Language> Languages,
IReadOnlyList<CustomFormat> CustomFormats,
int CustomFormatScore,
long Size,
string Title,
long Sizeleft,
string Timeleft,
DateTime EstimatedCompletionTime,
DateTime Added,
string Status,
string TrackedDownloadStatus,
string TrackedDownloadState,
IReadOnlyList<StatusMessage> StatusMessages,
string DownloadId,
string Protocol,
string DownloadClient,
bool DownloadClientHasPostImportCategory,
string Indexer,
string OutputPath,
bool EpisodeHasFile,
int Id
);

View File

@@ -1,7 +0,0 @@
namespace Domain.Sonarr.Queue;
public record Revision(
int Version,
int Real,
bool IsRepack
);

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record StatusMessage(
string Title,
IReadOnlyList<string> Messages
);

View File

@@ -0,0 +1,8 @@
namespace Domain.Sonarr;
public sealed record SonarrCommand
{
public required string Name { get; init; }
public required int SeriesId { get; set; }
}

View File

@@ -1,6 +1,7 @@
using Common.Configuration;
using Executable.Jobs;
using Infrastructure.Verticals.BlockedTorrent;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.QueueCleaner;
namespace Executable;
using Quartz;
@@ -17,44 +18,46 @@ public static class DependencyInjection
private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
services
.Configure<QuartzConfig>(configuration.GetSection(nameof(QuartzConfig)))
.Configure<QBitConfig>(configuration.GetSection(nameof(QBitConfig)))
.Configure<SonarrConfig>(configuration.GetSection(nameof(SonarrConfig)));
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName));
private static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddTransient<BlockedTorrentJob>()
.AddTransient<BlockedTorrentHandler>();
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<QueueCleanerJob>()
.AddTransient<QueueCleanerHandler>();
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
services
.AddQuartz(q =>
{
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
TriggersConfig? config = configuration.GetRequiredSection(TriggersConfig.SectionName).Get<TriggersConfig>();
if (config is null)
{
throw new NullReferenceException("Quartz configuration is null");
}
q.AddBlockedTorrentJob(config.BlockedTorrentTrigger);
q.AddQueueCleanerJob(config.QueueCleaner);
})
.AddQuartzHostedService(opt =>
{
opt.WaitForJobsToComplete = true;
});
private static void AddBlockedTorrentJob(this IServiceCollectionQuartzConfigurator q, string trigger)
private static void AddQueueCleanerJob(this IServiceCollectionQuartzConfigurator q, string trigger)
{
q.AddJob<BlockedTorrentJob>(opts =>
q.AddJob<QueueCleanerJob>(opts =>
{
opts.WithIdentity(nameof(BlockedTorrentJob));
opts.WithIdentity(nameof(QueueCleanerJob));
});
q.AddTrigger(opts =>
{
opts.ForJob(nameof(BlockedTorrentJob))
.WithIdentity($"{nameof(BlockedTorrentJob)}-trigger")
opts.ForJob(nameof(QueueCleanerJob))
.WithIdentity($"{nameof(QueueCleanerJob)}-trigger")
.WithCronSchedule(trigger);
});
}

View File

@@ -1,29 +0,0 @@
using Infrastructure.Verticals.BlockedTorrent;
using Quartz;
namespace Executable.Jobs;
[DisallowConcurrentExecution]
public sealed class BlockedTorrentJob : IJob
{
private ILogger<BlockedTorrentJob> _logger;
private BlockedTorrentHandler _handler;
public BlockedTorrentJob(ILogger<BlockedTorrentJob> logger, BlockedTorrentHandler handler)
{
_logger = logger;
_handler = handler;
}
public async Task Execute(IJobExecutionContext context)
{
try
{
await _handler.HandleAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(BlockedTorrentJob)} failed");
}
}
}

View File

@@ -0,0 +1,29 @@
using Infrastructure.Verticals.QueueCleaner;
using Quartz;
namespace Executable.Jobs;
[DisallowConcurrentExecution]
public sealed class QueueCleanerJob : IJob
{
private ILogger<QueueCleanerJob> _logger;
private QueueCleanerHandler _handler;
public QueueCleanerJob(ILogger<QueueCleanerJob> logger, QueueCleanerHandler handler)
{
_logger = logger;
_handler = handler;
}
public async Task Execute(IJobExecutionContext context)
{
try
{
await _handler.HandleAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(QueueCleanerJob)} failed");
}
}
}

View File

@@ -5,7 +5,7 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"QuartzConfig": {
"BlockedTorrentTrigger": "0 0/1 * * * ?"
"Triggers": {
"QueueCleaner": "0 0/1 * * * ?"
}
}

View File

@@ -7,20 +7,30 @@
"System.Net.Http.HttpClient": "Error"
}
},
"QuartzConfig": {
"BlockedTorrentTrigger": "0 0/5 * * * ?"
"Triggers": {
"QueueCleaner": "0 0/5 * * * ?"
},
"QBitConfig": {
"qBittorrent": {
"Url": "http://localhost:8080",
"Username": "",
"Password": ""
},
"SonarrConfig": {
"Sonarr": {
"Enabled": true,
"Instances": [
{
"Url": "http://localhost:8989",
"ApiKey": ""
}
]
},
"Radarr": {
"Enabled": false,
"Instances": [
{
"Url": "http://localhost:7878",
"ApiKey": ""
}
]
}
}

View File

@@ -0,0 +1,77 @@
using Common.Configuration;
using Domain.Arr.Queue;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public abstract class ArrClient
{
private protected ILogger<ArrClient> _logger;
private protected HttpClient _httpClient;
protected ArrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient();
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{
Uri uri = new(arrInstance.Url, $"/api/v3/queue?page={page}&pageSize=200&sortKey=timeleft");
using HttpRequestMessage request = new(HttpMethod.Get, uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try
{
response.EnsureSuccessStatusCode();
}
catch
{
_logger.LogError("queue list failed | {uri}", uri);
throw;
}
string responseBody = await response.Content.ReadAsStringAsync();
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
if (queueResponse is null)
{
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}");
}
return queueResponse;
}
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
{
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
using HttpRequestMessage request = new(HttpMethod.Delete, uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("queue item deleted | {url} | {title}", arrInstance.Url, queueRecord.Title);
}
catch
{
_logger.LogError("queue delete failed | {uri} | {title}", uri, queueRecord.Title);
throw;
}
}
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<int> itemIds);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{
request.Headers.Add("x-api-key", apiKey);
}
}

View File

@@ -0,0 +1,52 @@
using System.Text;
using Common.Configuration;
using Domain.Radarr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public sealed class RadarrClient : ArrClient
{
public RadarrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<int> itemIds)
{
if (itemIds.Count is 0)
{
return;
}
Uri uri = new(arrInstance.Url, "/api/v3/command");
RadarrCommand command = new()
{
Name = "MoviesSearch",
MovieIds = itemIds
};
using HttpRequestMessage request = new(HttpMethod.Post, uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("movie search triggered | {url} | movie ids: {ids}", arrInstance.Url, string.Join(",", itemIds));
}
catch
{
_logger.LogError("movie search failed | {url} | movie ids: {ids}", arrInstance.Url, string.Join(",", itemIds));
throw;
}
}
}

View File

@@ -0,0 +1,50 @@
using System.Text;
using Common.Configuration;
using Domain.Sonarr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public sealed class SonarrClient : ArrClient
{
public SonarrClient(ILogger<SonarrClient> logger, IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<int> itemIds)
{
foreach (int itemId in itemIds)
{
Uri uri = new(arrInstance.Url, "/api/v3/command");
SonarrCommand command = new()
{
Name = "SeriesSearch",
SeriesId = itemId
};
using HttpRequestMessage request = new(HttpMethod.Post, uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("series search triggered | {url} | series id: {id}", arrInstance.Url, itemId);
}
catch
{
_logger.LogError("series search failed | {url} | series id: {id}", arrInstance.Url, itemId);
throw;
}
}
}
}

View File

@@ -1,175 +0,0 @@
using System.Text;
using Common.Configuration;
using Domain.Sonarr.Queue;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using QBittorrent.Client;
namespace Infrastructure.Verticals.BlockedTorrent;
public sealed class BlockedTorrentHandler
{
private readonly ILogger<BlockedTorrentHandler> _logger;
private readonly QBitConfig _qBitConfig;
private readonly SonarrConfig _sonarrConfig;
private readonly HttpClient _httpClient;
private const string QueueListPathTemplate = "/api/v3/queue?page={0}&pageSize=200&sortKey=timeleft";
private const string QueueDeletePathTemplate = "/api/v3/queue/{0}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false";
private const string SonarrCommandUriPath = "/api/v3/command";
private const string SearchCommandPayloadTemplate = "{\"name\":\"SeriesSearch\",\"seriesId\":{0}}";
public BlockedTorrentHandler(
ILogger<BlockedTorrentHandler> logger,
IOptions<QBitConfig> qBitConfig,
IOptions<SonarrConfig> sonarrConfig,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_qBitConfig = qBitConfig.Value;
_sonarrConfig = sonarrConfig.Value;
_httpClient = httpClientFactory.CreateClient();
}
public async Task HandleAsync()
{
QBittorrentClient qBitClient = new(_qBitConfig.Url);
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
foreach (SonarrInstance sonarrInstance in _sonarrConfig.Instances)
{
ushort page = 1;
int totalRecords = 0;
int processedRecords = 0;
HashSet<int> seriesToBeRefreshed = [];
do
{
QueueListResponse queueResponse = await ListQueuedTorrentsAsync(sonarrInstance, page);
if (totalRecords is 0)
{
totalRecords = queueResponse.TotalRecords;
_logger.LogInformation(
"{items} items found in queue | {url}",
queueResponse.TotalRecords, sonarrInstance.Url);
}
foreach (Record record in queueResponse.Records)
{
var torrent = (await qBitClient.GetTorrentListAsync(new TorrentListQuery { Hashes = [record.DownloadId] }))
.FirstOrDefault();
if (torrent is not { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogInformation("skip | {torrent}", record.Title);
return;
}
seriesToBeRefreshed.Add(record.SeriesId);
await DeleteTorrentFromQueueAsync(sonarrInstance, record);
}
if (queueResponse.Records.Count is 0)
{
break;
}
processedRecords += queueResponse.Records.Count;
if (processedRecords >= totalRecords)
{
break;
}
page++;
} while (processedRecords < totalRecords);
foreach (int id in seriesToBeRefreshed)
{
await RefreshSeriesAsync(sonarrInstance, id);
}
}
}
private async Task<QueueListResponse> ListQueuedTorrentsAsync(SonarrInstance sonarrInstance, int page)
{
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueListPathTemplate, page));
using HttpRequestMessage sonarrRequest = new(HttpMethod.Get, sonarrUri);
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
try
{
response.EnsureSuccessStatusCode();
}
catch
{
_logger.LogError("queue list failed | {uri}", sonarrUri);
throw;
}
string responseBody = await response.Content.ReadAsStringAsync();
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
if (queueResponse is null)
{
throw new Exception($"unrecognized response | {responseBody}");
}
return queueResponse;
}
private async Task DeleteTorrentFromQueueAsync(SonarrInstance sonarrInstance, Record record)
{
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueDeletePathTemplate, record.Id));
using HttpRequestMessage sonarrRequest = new(HttpMethod.Delete, sonarrUri);
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("queue item deleted | {record}", record.Title);
}
catch
{
_logger.LogError("queue delete failed | {uri}", sonarrUri);
throw;
}
}
private async Task RefreshSeriesAsync(SonarrInstance sonarrInstance, int seriesId)
{
Uri sonarrUri = new(sonarrInstance.Url, SonarrCommandUriPath);
using HttpRequestMessage sonarrRequest = new(HttpMethod.Post, sonarrUri);
sonarrRequest.Content = new StringContent(
SearchCommandPayloadTemplate.Replace("{0}", seriesId.ToString()),
Encoding.UTF8,
"application/json"
);
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("series search triggered | series id: {id}", seriesId);
}
catch
{
_logger.LogError("series search failed | series id: {id}", seriesId);
throw;
}
}
}

View File

@@ -0,0 +1,140 @@
using Common.Configuration;
using Domain.Arr.Enums;
using Domain.Arr.Queue;
using Infrastructure.Verticals.Arr;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using QBittorrent.Client;
namespace Infrastructure.Verticals.QueueCleaner;
public sealed class QueueCleanerHandler
{
private readonly ILogger<QueueCleanerHandler> _logger;
private readonly QBitConfig _qBitConfig;
private readonly SonarrConfig _sonarrConfig;
private readonly RadarrConfig _radarrConfig;
private readonly SonarrClient _sonarrClient;
private readonly RadarrClient _radarrClient;
public QueueCleanerHandler(
ILogger<QueueCleanerHandler> logger,
IOptions<QBitConfig> qBitConfig,
IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig,
SonarrClient sonarrClient,
RadarrClient radarrClient)
{
_logger = logger;
_qBitConfig = qBitConfig.Value;
_sonarrConfig = sonarrConfig.Value;
_radarrConfig = radarrConfig.Value;
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
}
public async Task HandleAsync()
{
QBittorrentClient qBitClient = new(_qBitConfig.Url);
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
await ProcessArrConfigAsync(qBitClient, _sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(qBitClient, _radarrConfig, InstanceType.Radarr);
}
private async Task ProcessArrConfigAsync(QBittorrentClient qBitClient, ArrConfig config, InstanceType instanceType)
{
if (!config.Enabled)
{
return;
}
foreach (ArrInstance arrInstance in config.Instances)
{
try
{
await ProcessInstanceAsync(qBitClient, arrInstance, instanceType);
}
catch (Exception exception)
{
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
}
}
}
private async Task ProcessInstanceAsync(QBittorrentClient qBitClient, ArrInstance instance, InstanceType instanceType)
{
ushort page = 1;
int totalRecords = 0;
int processedRecords = 0;
HashSet<int> itemsToBeRefreshed = [];
ArrClient arrClient = GetClient(instanceType);
do
{
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(instance, page);
if (totalRecords is 0)
{
totalRecords = queueResponse.TotalRecords;
_logger.LogInformation(
"{items} items found in queue | {url}",
queueResponse.TotalRecords, instance.Url);
}
foreach (QueueRecord record in queueResponse.Records)
{
if (record.Protocol is not "torrent")
{
continue;
}
TorrentInfo? torrent = (await qBitClient.GetTorrentListAsync(new TorrentListQuery { Hashes = [record.DownloadId] }))
.FirstOrDefault();
if (torrent is not { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogInformation("skip | {torrent}", record.Title);
return;
}
itemsToBeRefreshed.Add(GetRecordId(instanceType, record));
await arrClient.DeleteQueueItemAsync(instance, record);
}
if (queueResponse.Records.Count is 0)
{
break;
}
processedRecords += queueResponse.Records.Count;
if (processedRecords >= totalRecords)
{
break;
}
page++;
} while (processedRecords < totalRecords);
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
}
private ArrClient GetClient(InstanceType type) =>
type switch
{
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
private int GetRecordId(InstanceType type, QueueRecord record) =>
type switch
{
InstanceType.Sonarr => record.SeriesId,
InstanceType.Radarr => record.MovieId,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}