added support for Radarr
This commit is contained in:
@@ -12,37 +12,49 @@
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
```
|
```
|
||||||
docker run \
|
docker run -d \
|
||||||
-e QuartzConfig__BlockedTorrentTrigger="0 0/10 * * * ?" \
|
-e TRIGGERS__QUEUECLEANER="0 0/5 * * * ?" \
|
||||||
-e QBitConfig__Url="http://localhost:8080" \
|
-e QBITTORRENT__URL="http://localhost:8080" \
|
||||||
-e QBitConfig__Username="user" \
|
-e QBITTORRENT__USERNAME="user" \
|
||||||
-e QBitConfig__Password="pass" \
|
-e QBITTORRENT__PASSWORD="pass" \
|
||||||
-e SonarrConfig__Instances__0__Url="http://localhost:8989" \
|
-e SONARR__ENABLED="true" \
|
||||||
-e SonarrConfig__Instances__0__ApiKey="secret1" \
|
-e SONARR__INSTANCES__0__URL="http://localhost:8989" \
|
||||||
-e SonarrConfig__Instances__1__Url="http://localhost:8990" \
|
-e SONARR__INSTANCES__0__APIKEY="secret1" \
|
||||||
-e SonarrConfig__Instances__1__ApiKey="secret2" \
|
-e SONARR__INSTANCES__1__URL="http://localhost:8990" \
|
||||||
|
-e SONARR__INSTANCES__1__APIKEY="secret2" \
|
||||||
|
-e RADARR__ENABLED="true" \
|
||||||
|
-e RADARR__INSTANCES__0__URL="http://localhost:7878" \
|
||||||
|
-e RADARR__INSTANCES__0__APIKEY="secret3" \
|
||||||
|
-e RADARR__INSTANCES__1__URL="http://localhost:7879" \
|
||||||
|
-e RADARR__INSTANCES__1__APIKEY="secret4" \
|
||||||
...
|
...
|
||||||
flaminel/cleanuperr:latest
|
flaminel/cleanuperr:1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Variable | Required | Description | Default value |
|
| Variable | Required | Description | Default value |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| QuartzConfig__BlockedTorrentTrigger | No | Quartz cron trigger | 0 0/5 * * * ? |
|
| TRIGGERS__QUEUECLEANER | No | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 0 0/5 * * * ? |
|
||||||
| QBitConfig__Url | Yes | qBittorrent instance url | http://localhost:8080 |
|
| QBITTORRENT__URL | Yes | qBittorrent instance url | http://localhost:8080 |
|
||||||
| QBitConfig__Username | Yes | qBittorrent user | empty |
|
| QBITTORRENT__USERNAME | Yes | qBittorrent user | empty |
|
||||||
| QBitConfig__Password | Yes | qBittorrent password | empty |
|
| QBITTORRENT__PASSWORD | Yes | qBittorrent password | empty |
|
||||||
| SonarrConfig__Instances__0__Url | Yes | First Sonarr instance url | http://localhost:8989 |
|
|
|
||||||
| SonarrConfig__Instances__0__ApiKey | Yes | First Sonarr instance API key | empty |
|
| SONARR__ENABLED | No | Whether Sonarr cleanup is enabled or not | true |
|
||||||
|
| SONARR__INSTANCES__0__URL | Yes | First Sonarr instance url | http://localhost:8989 |
|
||||||
|
| SONARR__INSTANCES__0__APIKEY | Yes | First Sonarr instance API key | empty |
|
||||||
|
|
|
||||||
|
| RADARR__ENABLED | No | Whether Radarr cleanup is enabled or not | false |
|
||||||
|
| RADARR__INSTANCES__0__URL | Yes | First Radarr instance url | http://localhost:8989 |
|
||||||
|
| RADARR__INSTANCES__0__APIKEY | Yes | First Radarr instance API key | empty |
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
Multiple Sonarr instances can be specified using this format:
|
Multiple Sonarr/Radarr instances can be specified using this format:
|
||||||
|
|
||||||
```
|
```
|
||||||
SonarrConfig__Instances__<NUMBER>__Url
|
SONARR__INSTANCES__<NUMBER>__URL
|
||||||
SonarrConfig__Instances__<NUMBER>__ApiKey
|
SONARR__INSTANCES__<NUMBER>__APIKEY
|
||||||
```
|
```
|
||||||
|
|
||||||
where `<NUMBER>` starts from 0.
|
where `<NUMBER>` starts from 0.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Common.Configuration;
|
||||||
|
|
||||||
|
public abstract record ArrConfig
|
||||||
|
{
|
||||||
|
public required bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public required List<ArrInstance> Instances { get; init; }
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
namespace Common.Configuration;
|
namespace Common.Configuration;
|
||||||
|
|
||||||
public sealed class SonarrInstance
|
public sealed class ArrInstance
|
||||||
{
|
{
|
||||||
public required Uri Url { get; set; }
|
public required Uri Url { get; set; }
|
||||||
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
public sealed class QBitConfig
|
public sealed class QBitConfig
|
||||||
{
|
{
|
||||||
|
public const string SectionName = "qBittorrent";
|
||||||
|
|
||||||
public required Uri Url { get; set; }
|
public required Uri Url { get; set; }
|
||||||
|
|
||||||
public required string Username { get; set; }
|
public required string Username { get; set; }
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Common.Configuration;
|
|
||||||
|
|
||||||
public sealed class QuartzConfig
|
|
||||||
{
|
|
||||||
public required string BlockedTorrentTrigger { get; init; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Common.Configuration;
|
||||||
|
|
||||||
|
public sealed record RadarrConfig : ArrConfig
|
||||||
|
{
|
||||||
|
public const string SectionName = "Radarr";
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace Common.Configuration;
|
namespace Common.Configuration;
|
||||||
|
|
||||||
public sealed class SonarrConfig
|
public sealed record SonarrConfig : ArrConfig
|
||||||
{
|
{
|
||||||
public required List<SonarrInstance> Instances { get; set; }
|
public const string SectionName = "Sonarr";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Common.Configuration;
|
||||||
|
|
||||||
|
public sealed class TriggersConfig
|
||||||
|
{
|
||||||
|
public const string SectionName = "Triggers";
|
||||||
|
|
||||||
|
public required string QueueCleaner { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Domain.Arr.Enums;
|
||||||
|
|
||||||
|
public enum InstanceType
|
||||||
|
{
|
||||||
|
Sonarr,
|
||||||
|
Radarr,
|
||||||
|
Lidarr,
|
||||||
|
Readarr
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Domain.Arr.Queue;
|
||||||
|
|
||||||
|
public record QueueListResponse
|
||||||
|
{
|
||||||
|
public required int TotalRecords { get; init; }
|
||||||
|
public required IReadOnlyList<QueueRecord> Records { get; init; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Domain.Radarr;
|
||||||
|
|
||||||
|
public sealed record RadarrCommand
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required HashSet<int> MovieIds { get; init; }
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Domain.Sonarr.Queue;
|
|
||||||
|
|
||||||
public record CustomFormat(
|
|
||||||
int Id,
|
|
||||||
string Name
|
|
||||||
);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Domain.Sonarr.Queue;
|
|
||||||
|
|
||||||
public record Language(
|
|
||||||
int Id,
|
|
||||||
string Name
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Domain.Sonarr.Queue;
|
|
||||||
|
|
||||||
public record QueueListResponse(
|
|
||||||
int Page,
|
|
||||||
int PageSize,
|
|
||||||
string SortKey,
|
|
||||||
string SortDirection,
|
|
||||||
int TotalRecords,
|
|
||||||
IReadOnlyList<Record> Records
|
|
||||||
);
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Domain.Sonarr.Queue;
|
|
||||||
|
|
||||||
public record Revision(
|
|
||||||
int Version,
|
|
||||||
int Real,
|
|
||||||
bool IsRepack
|
|
||||||
);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Domain.Sonarr.Queue;
|
|
||||||
|
|
||||||
public record StatusMessage(
|
|
||||||
string Title,
|
|
||||||
IReadOnlyList<string> Messages
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Domain.Sonarr;
|
||||||
|
|
||||||
|
public sealed record SonarrCommand
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required int SeriesId { get; set; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration;
|
||||||
using Executable.Jobs;
|
using Executable.Jobs;
|
||||||
using Infrastructure.Verticals.BlockedTorrent;
|
using Infrastructure.Verticals.Arr;
|
||||||
|
using Infrastructure.Verticals.QueueCleaner;
|
||||||
|
|
||||||
namespace Executable;
|
namespace Executable;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
@@ -17,44 +18,46 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||||
services
|
services
|
||||||
.Configure<QuartzConfig>(configuration.GetSection(nameof(QuartzConfig)))
|
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||||
.Configure<QBitConfig>(configuration.GetSection(nameof(QBitConfig)))
|
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
|
||||||
.Configure<SonarrConfig>(configuration.GetSection(nameof(SonarrConfig)));
|
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName));
|
||||||
|
|
||||||
private static IServiceCollection AddServices(this IServiceCollection services) =>
|
private static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||||
services
|
services
|
||||||
.AddTransient<BlockedTorrentJob>()
|
.AddTransient<SonarrClient>()
|
||||||
.AddTransient<BlockedTorrentHandler>();
|
.AddTransient<RadarrClient>()
|
||||||
|
.AddTransient<QueueCleanerJob>()
|
||||||
|
.AddTransient<QueueCleanerHandler>();
|
||||||
|
|
||||||
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||||
services
|
services
|
||||||
.AddQuartz(q =>
|
.AddQuartz(q =>
|
||||||
{
|
{
|
||||||
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
|
TriggersConfig? config = configuration.GetRequiredSection(TriggersConfig.SectionName).Get<TriggersConfig>();
|
||||||
|
|
||||||
if (config is null)
|
if (config is null)
|
||||||
{
|
{
|
||||||
throw new NullReferenceException("Quartz configuration is null");
|
throw new NullReferenceException("Quartz configuration is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
q.AddBlockedTorrentJob(config.BlockedTorrentTrigger);
|
q.AddQueueCleanerJob(config.QueueCleaner);
|
||||||
})
|
})
|
||||||
.AddQuartzHostedService(opt =>
|
.AddQuartzHostedService(opt =>
|
||||||
{
|
{
|
||||||
opt.WaitForJobsToComplete = true;
|
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 =>
|
q.AddTrigger(opts =>
|
||||||
{
|
{
|
||||||
opts.ForJob(nameof(BlockedTorrentJob))
|
opts.ForJob(nameof(QueueCleanerJob))
|
||||||
.WithIdentity($"{nameof(BlockedTorrentJob)}-trigger")
|
.WithIdentity($"{nameof(QueueCleanerJob)}-trigger")
|
||||||
.WithCronSchedule(trigger);
|
.WithCronSchedule(trigger);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"QuartzConfig": {
|
"Triggers": {
|
||||||
"BlockedTorrentTrigger": "0 0/1 * * * ?"
|
"QueueCleaner": "0 0/1 * * * ?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,30 @@
|
|||||||
"System.Net.Http.HttpClient": "Error"
|
"System.Net.Http.HttpClient": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"QuartzConfig": {
|
"Triggers": {
|
||||||
"BlockedTorrentTrigger": "0 0/5 * * * ?"
|
"QueueCleaner": "0 0/5 * * * ?"
|
||||||
},
|
},
|
||||||
"QBitConfig": {
|
"qBittorrent": {
|
||||||
"Url": "http://localhost:8080",
|
"Url": "http://localhost:8080",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": ""
|
"Password": ""
|
||||||
},
|
},
|
||||||
"SonarrConfig": {
|
"Sonarr": {
|
||||||
|
"Enabled": true,
|
||||||
"Instances": [
|
"Instances": [
|
||||||
{
|
{
|
||||||
"Url": "http://localhost:8989",
|
"Url": "http://localhost:8989",
|
||||||
"ApiKey": ""
|
"ApiKey": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Radarr": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Instances": [
|
||||||
|
{
|
||||||
|
"Url": "http://localhost:7878",
|
||||||
|
"ApiKey": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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