diff --git a/README.md b/README.md index 94837bb8..ea0f3f1b 100644 --- a/README.md +++ b/README.md @@ -12,37 +12,49 @@ ### Docker ``` -docker run \ - -e QuartzConfig__BlockedTorrentTrigger="0 0/10 * * * ?" \ - -e QBitConfig__Url="http://localhost:8080" \ - -e QBitConfig__Username="user" \ - -e QBitConfig__Password="pass" \ - -e SonarrConfig__Instances__0__Url="http://localhost:8989" \ - -e SonarrConfig__Instances__0__ApiKey="secret1" \ - -e SonarrConfig__Instances__1__Url="http://localhost:8990" \ - -e SonarrConfig__Instances__1__ApiKey="secret2" \ +docker run -d \ + -e TRIGGERS__QUEUECLEANER="0 0/5 * * * ?" \ + -e QBITTORRENT__URL="http://localhost:8080" \ + -e QBITTORRENT__USERNAME="user" \ + -e QBITTORRENT__PASSWORD="pass" \ + -e SONARR__ENABLED="true" \ + -e SONARR__INSTANCES__0__URL="http://localhost:8989" \ + -e SONARR__INSTANCES__0__APIKEY="secret1" \ + -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 | Variable | Required | Description | Default value | |---|---|---|---| -| QuartzConfig__BlockedTorrentTrigger | No | Quartz cron trigger | 0 0/5 * * * ? | -| QBitConfig__Url | Yes | qBittorrent instance url | http://localhost:8080 | -| QBitConfig__Username | Yes | qBittorrent user | empty | -| QBitConfig__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 | +| TRIGGERS__QUEUECLEANER | No | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 0 0/5 * * * ? | +| QBITTORRENT__URL | Yes | qBittorrent instance url | http://localhost:8080 | +| QBITTORRENT__USERNAME | Yes | qBittorrent user | empty | +| QBITTORRENT__PASSWORD | Yes | qBittorrent password | 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____Url -SonarrConfig__Instances____ApiKey +SONARR__INSTANCES____URL +SONARR__INSTANCES____APIKEY ``` where `` starts from 0. diff --git a/code/Common/Configuration/ArrConfig.cs b/code/Common/Configuration/ArrConfig.cs new file mode 100644 index 00000000..cc28063f --- /dev/null +++ b/code/Common/Configuration/ArrConfig.cs @@ -0,0 +1,8 @@ +namespace Common.Configuration; + +public abstract record ArrConfig +{ + public required bool Enabled { get; init; } + + public required List Instances { get; init; } +} \ No newline at end of file diff --git a/code/Common/Configuration/SonarrInstance.cs b/code/Common/Configuration/ArrInstance.cs similarity index 78% rename from code/Common/Configuration/SonarrInstance.cs rename to code/Common/Configuration/ArrInstance.cs index 98380537..a82f5dcf 100644 --- a/code/Common/Configuration/SonarrInstance.cs +++ b/code/Common/Configuration/ArrInstance.cs @@ -1,6 +1,6 @@ namespace Common.Configuration; -public sealed class SonarrInstance +public sealed class ArrInstance { public required Uri Url { get; set; } diff --git a/code/Common/Configuration/QBitConfig.cs b/code/Common/Configuration/QBitConfig.cs index 53c78cc9..3b7b3632 100644 --- a/code/Common/Configuration/QBitConfig.cs +++ b/code/Common/Configuration/QBitConfig.cs @@ -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; } diff --git a/code/Common/Configuration/QuartzConfig.cs b/code/Common/Configuration/QuartzConfig.cs deleted file mode 100644 index 448beec6..00000000 --- a/code/Common/Configuration/QuartzConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Common.Configuration; - -public sealed class QuartzConfig -{ - public required string BlockedTorrentTrigger { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/RadarrConfig.cs b/code/Common/Configuration/RadarrConfig.cs new file mode 100644 index 00000000..211d422c --- /dev/null +++ b/code/Common/Configuration/RadarrConfig.cs @@ -0,0 +1,6 @@ +namespace Common.Configuration; + +public sealed record RadarrConfig : ArrConfig +{ + public const string SectionName = "Radarr"; +} \ No newline at end of file diff --git a/code/Common/Configuration/SonarrConfig.cs b/code/Common/Configuration/SonarrConfig.cs index cbd25e8d..5735ff39 100644 --- a/code/Common/Configuration/SonarrConfig.cs +++ b/code/Common/Configuration/SonarrConfig.cs @@ -1,6 +1,6 @@ namespace Common.Configuration; -public sealed class SonarrConfig +public sealed record SonarrConfig : ArrConfig { - public required List Instances { get; set; } + public const string SectionName = "Sonarr"; } \ No newline at end of file diff --git a/code/Common/Configuration/TriggersConfig.cs b/code/Common/Configuration/TriggersConfig.cs new file mode 100644 index 00000000..72444621 --- /dev/null +++ b/code/Common/Configuration/TriggersConfig.cs @@ -0,0 +1,8 @@ +namespace Common.Configuration; + +public sealed class TriggersConfig +{ + public const string SectionName = "Triggers"; + + public required string QueueCleaner { get; init; } +} \ No newline at end of file diff --git a/code/Domain/Arr/Enums/InstanceType.cs b/code/Domain/Arr/Enums/InstanceType.cs new file mode 100644 index 00000000..5ef14d58 --- /dev/null +++ b/code/Domain/Arr/Enums/InstanceType.cs @@ -0,0 +1,9 @@ +namespace Domain.Arr.Enums; + +public enum InstanceType +{ + Sonarr, + Radarr, + Lidarr, + Readarr +} \ No newline at end of file diff --git a/code/Domain/Arr/Queue/QueueListResponse.cs b/code/Domain/Arr/Queue/QueueListResponse.cs new file mode 100644 index 00000000..685bb4e0 --- /dev/null +++ b/code/Domain/Arr/Queue/QueueListResponse.cs @@ -0,0 +1,7 @@ +namespace Domain.Arr.Queue; + +public record QueueListResponse +{ + public required int TotalRecords { get; init; } + public required IReadOnlyList Records { get; init; } +} \ No newline at end of file diff --git a/code/Domain/Arr/Queue/QueueRecord.cs b/code/Domain/Arr/Queue/QueueRecord.cs new file mode 100644 index 00000000..0748c503 --- /dev/null +++ b/code/Domain/Arr/Queue/QueueRecord.cs @@ -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; } +} \ No newline at end of file diff --git a/code/Domain/Radarr/RadarrCommand.cs b/code/Domain/Radarr/RadarrCommand.cs new file mode 100644 index 00000000..7ed29063 --- /dev/null +++ b/code/Domain/Radarr/RadarrCommand.cs @@ -0,0 +1,8 @@ +namespace Domain.Radarr; + +public sealed record RadarrCommand +{ + public required string Name { get; init; } + + public required HashSet MovieIds { get; init; } +} \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/CustomFormat.cs b/code/Domain/Sonarr/Queue/CustomFormat.cs deleted file mode 100644 index b51d1044..00000000 --- a/code/Domain/Sonarr/Queue/CustomFormat.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record CustomFormat( - int Id, - string Name -); \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/Language.cs b/code/Domain/Sonarr/Queue/Language.cs deleted file mode 100644 index 38be3c12..00000000 --- a/code/Domain/Sonarr/Queue/Language.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record Language( - int Id, - string Name -); \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/QueueListResponse.cs b/code/Domain/Sonarr/Queue/QueueListResponse.cs deleted file mode 100644 index 6fff64cd..00000000 --- a/code/Domain/Sonarr/Queue/QueueListResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record QueueListResponse( - int Page, - int PageSize, - string SortKey, - string SortDirection, - int TotalRecords, - IReadOnlyList Records -); \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/Record.cs b/code/Domain/Sonarr/Queue/Record.cs deleted file mode 100644 index 961f86d1..00000000 --- a/code/Domain/Sonarr/Queue/Record.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record Record( - int SeriesId, - int EpisodeId, - int SeasonNumber, - IReadOnlyList Languages, - IReadOnlyList CustomFormats, - int CustomFormatScore, - long Size, - string Title, - long Sizeleft, - string Timeleft, - DateTime EstimatedCompletionTime, - DateTime Added, - string Status, - string TrackedDownloadStatus, - string TrackedDownloadState, - IReadOnlyList StatusMessages, - string DownloadId, - string Protocol, - string DownloadClient, - bool DownloadClientHasPostImportCategory, - string Indexer, - string OutputPath, - bool EpisodeHasFile, - int Id -); \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/Revision.cs b/code/Domain/Sonarr/Queue/Revision.cs deleted file mode 100644 index 76f4bcd9..00000000 --- a/code/Domain/Sonarr/Queue/Revision.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record Revision( - int Version, - int Real, - bool IsRepack -); \ No newline at end of file diff --git a/code/Domain/Sonarr/Queue/StatusMessage.cs b/code/Domain/Sonarr/Queue/StatusMessage.cs deleted file mode 100644 index d15c8336..00000000 --- a/code/Domain/Sonarr/Queue/StatusMessage.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Domain.Sonarr.Queue; - -public record StatusMessage( - string Title, - IReadOnlyList Messages -); \ No newline at end of file diff --git a/code/Domain/Sonarr/SonarrCommand.cs b/code/Domain/Sonarr/SonarrCommand.cs new file mode 100644 index 00000000..03b60384 --- /dev/null +++ b/code/Domain/Sonarr/SonarrCommand.cs @@ -0,0 +1,8 @@ +namespace Domain.Sonarr; + +public sealed record SonarrCommand +{ + public required string Name { get; init; } + + public required int SeriesId { get; set; } +} \ No newline at end of file diff --git a/code/Executable/DependencyInjection.cs b/code/Executable/DependencyInjection.cs index 8d6c6b11..e36f31f3 100644 --- a/code/Executable/DependencyInjection.cs +++ b/code/Executable/DependencyInjection.cs @@ -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(configuration.GetSection(nameof(QuartzConfig))) - .Configure(configuration.GetSection(nameof(QBitConfig))) - .Configure(configuration.GetSection(nameof(SonarrConfig))); + .Configure(configuration.GetSection(QBitConfig.SectionName)) + .Configure(configuration.GetSection(SonarrConfig.SectionName)) + .Configure(configuration.GetSection(RadarrConfig.SectionName)); private static IServiceCollection AddServices(this IServiceCollection services) => services - .AddTransient() - .AddTransient(); + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient(); private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) => services .AddQuartz(q => { - QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get(); + TriggersConfig? config = configuration.GetRequiredSection(TriggersConfig.SectionName).Get(); 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(opts => + q.AddJob(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); }); } diff --git a/code/Executable/Jobs/BlockedTorrentJob.cs b/code/Executable/Jobs/BlockedTorrentJob.cs deleted file mode 100644 index c1287a11..00000000 --- a/code/Executable/Jobs/BlockedTorrentJob.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Infrastructure.Verticals.BlockedTorrent; -using Quartz; - -namespace Executable.Jobs; - -[DisallowConcurrentExecution] -public sealed class BlockedTorrentJob : IJob -{ - private ILogger _logger; - private BlockedTorrentHandler _handler; - - public BlockedTorrentJob(ILogger 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"); - } - } -} \ No newline at end of file diff --git a/code/Executable/Jobs/QueueCleanerJob.cs b/code/Executable/Jobs/QueueCleanerJob.cs new file mode 100644 index 00000000..d814bee4 --- /dev/null +++ b/code/Executable/Jobs/QueueCleanerJob.cs @@ -0,0 +1,29 @@ +using Infrastructure.Verticals.QueueCleaner; +using Quartz; + +namespace Executable.Jobs; + +[DisallowConcurrentExecution] +public sealed class QueueCleanerJob : IJob +{ + private ILogger _logger; + private QueueCleanerHandler _handler; + + public QueueCleanerJob(ILogger 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"); + } + } +} \ No newline at end of file diff --git a/code/Executable/appsettings.Development.json b/code/Executable/appsettings.Development.json index 8da7fb77..85866fa0 100644 --- a/code/Executable/appsettings.Development.json +++ b/code/Executable/appsettings.Development.json @@ -5,7 +5,7 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "QuartzConfig": { - "BlockedTorrentTrigger": "0 0/1 * * * ?" + "Triggers": { + "QueueCleaner": "0 0/1 * * * ?" } } diff --git a/code/Executable/appsettings.json b/code/Executable/appsettings.json index e8250e4a..ad174238 100644 --- a/code/Executable/appsettings.json +++ b/code/Executable/appsettings.json @@ -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": "" + } + ] } } diff --git a/code/Infrastructure/Verticals/Arr/ArrClient.cs b/code/Infrastructure/Verticals/Arr/ArrClient.cs new file mode 100644 index 00000000..18e6fb62 --- /dev/null +++ b/code/Infrastructure/Verticals/Arr/ArrClient.cs @@ -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 _logger; + private protected HttpClient _httpClient; + + protected ArrClient(ILogger logger, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClient = httpClientFactory.CreateClient(); + } + + public virtual async Task 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(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 itemIds); + + protected virtual void SetApiKey(HttpRequestMessage request, string apiKey) + { + request.Headers.Add("x-api-key", apiKey); + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/RadarrClient.cs b/code/Infrastructure/Verticals/Arr/RadarrClient.cs new file mode 100644 index 00000000..95067b1b --- /dev/null +++ b/code/Infrastructure/Verticals/Arr/RadarrClient.cs @@ -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 logger, IHttpClientFactory httpClientFactory) + : base(logger, httpClientFactory) + { + } + + public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet 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; + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/SonarrClient.cs b/code/Infrastructure/Verticals/Arr/SonarrClient.cs new file mode 100644 index 00000000..bcfaab47 --- /dev/null +++ b/code/Infrastructure/Verticals/Arr/SonarrClient.cs @@ -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 logger, IHttpClientFactory httpClientFactory) + : base(logger, httpClientFactory) + { + } + + public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet 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; + } + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/BlockedTorrent/BlockedTorrentHandler.cs b/code/Infrastructure/Verticals/BlockedTorrent/BlockedTorrentHandler.cs deleted file mode 100644 index 9fa7d5be..00000000 --- a/code/Infrastructure/Verticals/BlockedTorrent/BlockedTorrentHandler.cs +++ /dev/null @@ -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 _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 logger, - IOptions qBitConfig, - IOptions 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 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 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(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; - } - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/QueueCleaner/QueueCleanerHandler.cs b/code/Infrastructure/Verticals/QueueCleaner/QueueCleanerHandler.cs new file mode 100644 index 00000000..ab62ba66 --- /dev/null +++ b/code/Infrastructure/Verticals/QueueCleaner/QueueCleanerHandler.cs @@ -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 _logger; + private readonly QBitConfig _qBitConfig; + private readonly SonarrConfig _sonarrConfig; + private readonly RadarrConfig _radarrConfig; + private readonly SonarrClient _sonarrClient; + private readonly RadarrClient _radarrClient; + + public QueueCleanerHandler( + ILogger logger, + IOptions qBitConfig, + IOptions sonarrConfig, + IOptions 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 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") + }; +} \ No newline at end of file