mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-02-28 04:17:27 -05:00
added support for Radarr
This commit is contained in:
77
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal file
77
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal file
52
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal file
50
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user