diff --git a/code/Common/Common.csproj b/code/Common/Common.csproj new file mode 100644 index 00000000..17b910f6 --- /dev/null +++ b/code/Common/Common.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/code/Common/Configuration/QBitConfig.cs b/code/Common/Configuration/QBitConfig.cs new file mode 100644 index 00000000..53c78cc9 --- /dev/null +++ b/code/Common/Configuration/QBitConfig.cs @@ -0,0 +1,10 @@ +namespace Common.Configuration; + +public sealed class QBitConfig +{ + public required Uri Url { get; set; } + + public required string Username { get; set; } + + public required string Password { get; set; } +} \ No newline at end of file diff --git a/code/Common/Configuration/QuartzConfig.cs b/code/Common/Configuration/QuartzConfig.cs new file mode 100644 index 00000000..3db3fed9 --- /dev/null +++ b/code/Common/Configuration/QuartzConfig.cs @@ -0,0 +1,6 @@ +namespace Common.Configuration; + +public sealed class QuartzConfig +{ + public required string FrozenTorrentTrigger { get; init; } +} \ No newline at end of file diff --git a/code/Common/Configuration/SonarrConfig.cs b/code/Common/Configuration/SonarrConfig.cs new file mode 100644 index 00000000..cbd25e8d --- /dev/null +++ b/code/Common/Configuration/SonarrConfig.cs @@ -0,0 +1,6 @@ +namespace Common.Configuration; + +public sealed class SonarrConfig +{ + public required List Instances { get; set; } +} \ No newline at end of file diff --git a/code/Common/Configuration/SonarrInstance.cs b/code/Common/Configuration/SonarrInstance.cs new file mode 100644 index 00000000..98380537 --- /dev/null +++ b/code/Common/Configuration/SonarrInstance.cs @@ -0,0 +1,8 @@ +namespace Common.Configuration; + +public sealed class SonarrInstance +{ + public required Uri Url { get; set; } + + public required string ApiKey { get; set; } +} \ No newline at end of file diff --git a/code/Domain/Domain.csproj b/code/Domain/Domain.csproj new file mode 100644 index 00000000..17b910f6 --- /dev/null +++ b/code/Domain/Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/code/Domain/Sonarr/Queue/CustomFormat.cs b/code/Domain/Sonarr/Queue/CustomFormat.cs new file mode 100644 index 00000000..b51d1044 --- /dev/null +++ b/code/Domain/Sonarr/Queue/CustomFormat.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..38be3c12 --- /dev/null +++ b/code/Domain/Sonarr/Queue/Language.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..6fff64cd --- /dev/null +++ b/code/Domain/Sonarr/Queue/QueueListResponse.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..ae5f5b53 --- /dev/null +++ b/code/Domain/Sonarr/Queue/Record.cs @@ -0,0 +1,28 @@ +namespace Domain.Sonarr.Queue; + +public record Record( + int SeriesId, + int EpisodeId, + int SeasonNumber, + IReadOnlyList Languages, + IReadOnlyList CustomFormats, + int CustomFormatScore, + int Size, + string Title, + int 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 new file mode 100644 index 00000000..76f4bcd9 --- /dev/null +++ b/code/Domain/Sonarr/Queue/Revision.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000..d15c8336 --- /dev/null +++ b/code/Domain/Sonarr/Queue/StatusMessage.cs @@ -0,0 +1,6 @@ +namespace Domain.Sonarr.Queue; + +public record StatusMessage( + string Title, + IReadOnlyList Messages +); \ No newline at end of file diff --git a/code/Executable/DependencyInjection.cs b/code/Executable/DependencyInjection.cs new file mode 100644 index 00000000..4e9a28af --- /dev/null +++ b/code/Executable/DependencyInjection.cs @@ -0,0 +1,59 @@ +using Common.Configuration; +using Executable.Jobs; +using Infrastructure.Verticals.FrozenTorrent; + +namespace Executable; +using Quartz; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) => + services + .AddLogging(builder => builder.AddConsole()) + .AddHttpClient() + .AddConfiguration(configuration) + .AddServices() + .AddQuartzServices(configuration); + + private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) => + services + .Configure(configuration.GetSection(nameof(QuartzConfig))) + .Configure(configuration.GetSection(nameof(SonarrConfig))); + + private static IServiceCollection AddServices(this IServiceCollection services) => + services + .AddTransient(); + + private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) => + services + .AddQuartz(q => + { + QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get(); + + if (config is null) + { + throw new NullReferenceException("Quartz configuration is null"); + } + + q.AddFrozenTorrentJob(config.FrozenTorrentTrigger); + }) + .AddQuartzHostedService(opt => + { + opt.WaitForJobsToComplete = true; + }); + + private static void AddFrozenTorrentJob(this IServiceCollectionQuartzConfigurator q, string trigger) + { + q.AddJob(opts => + { + opts.WithIdentity(nameof(FrozenTorrentJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(FrozenTorrentJob)) + .WithIdentity($"{nameof(FrozenTorrentJob)}-trigger") + .WithCronSchedule(trigger); + }); + } +} \ No newline at end of file diff --git a/code/Executable/Executable.csproj b/code/Executable/Executable.csproj new file mode 100644 index 00000000..98b0b1af --- /dev/null +++ b/code/Executable/Executable.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8 + + + + + + + + + + + + + + + diff --git a/code/Executable/Jobs/FrozenTorrentJob.cs b/code/Executable/Jobs/FrozenTorrentJob.cs new file mode 100644 index 00000000..07d91ea7 --- /dev/null +++ b/code/Executable/Jobs/FrozenTorrentJob.cs @@ -0,0 +1,29 @@ +using Infrastructure.Verticals.FrozenTorrent; +using Quartz; + +namespace Executable.Jobs; + +[DisallowConcurrentExecution] +public sealed class FrozenTorrentJob : IJob +{ + private ILogger _logger; + private FrozenTorrentHandler _handler; + + public FrozenTorrentJob(ILogger logger, FrozenTorrentHandler handler) + { + _logger = logger; + _handler = handler; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + await _handler.HandleAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"{nameof(FrozenTorrentJob)} failed"); + } + } +} \ No newline at end of file diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs new file mode 100644 index 00000000..1e253c08 --- /dev/null +++ b/code/Executable/Program.cs @@ -0,0 +1,9 @@ +using Executable; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddInfrastructure(builder.Configuration); + +var host = builder.Build(); + +host.Run(); \ No newline at end of file diff --git a/code/Executable/appsettings.Development.json b/code/Executable/appsettings.Development.json new file mode 100644 index 00000000..b2dcdb67 --- /dev/null +++ b/code/Executable/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/code/Executable/appsettings.json b/code/Executable/appsettings.json new file mode 100644 index 00000000..d3f0686c --- /dev/null +++ b/code/Executable/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Warning", + "Quartz": "Warning" + } + }, + "QuartzConfig": { + "FrozenTorrentTrigger": "0 0/5 * * * ?" + }, + "QBitConfig": { + "Url": "http://localhost:8080", + "Username": "", + "Password": "" + }, + "SonarrConfig": [ + { + "Url": "http://localhost:8989", + "ApiKey": "" + } + ] +} diff --git a/code/Infrastructure/Infrastructure.csproj b/code/Infrastructure/Infrastructure.csproj new file mode 100644 index 00000000..23b3f68b --- /dev/null +++ b/code/Infrastructure/Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/code/Infrastructure/Verticals/FrozenTorrent/FrozenTorrentHandler.cs b/code/Infrastructure/Verticals/FrozenTorrent/FrozenTorrentHandler.cs new file mode 100644 index 00000000..51450e79 --- /dev/null +++ b/code/Infrastructure/Verticals/FrozenTorrent/FrozenTorrentHandler.cs @@ -0,0 +1,106 @@ +using Common.Configuration; +using Domain.Sonarr.Queue; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using QBittorrent.Client; + +namespace Infrastructure.Verticals.FrozenTorrent; + +public sealed class FrozenTorrentHandler +{ + private readonly ILogger _logger; + private readonly QBitConfig _qBitConfig; + private readonly SonarrConfig _sonarrConfig; + private readonly HttpClient _httpClient; + + private const string SonarListUriTemplate = "/api/v3/queue?page={0}&pageSize=200&sortKey=timeleft"; + private const string SonarDeleteUriTemplate = "/api/v3/queue/{0}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false"; + + public FrozenTorrentHandler( + 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; + + do + { + UriBuilder uriBuilder = new UriBuilder(sonarrInstance.Url); + uriBuilder.Path = string.Format(SonarListUriTemplate, page); + + HttpRequestMessage sonarrRequest = new(HttpMethod.Get, uriBuilder.Uri); + sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey); + + HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest); + response.EnsureSuccessStatusCode(); + + string responseBody = await response.Content.ReadAsStringAsync(); + QueueListResponse? queueResponse = JsonConvert.DeserializeObject(responseBody); + + if (queueResponse is null) + { + throw new Exception($"Failed to process response body:{responseBody}"); + } + + 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 }) + { + // TODO log skip + continue; + } + + // delete and block from sonarr + uriBuilder = new(sonarrInstance.Url); + uriBuilder.Path = string.Format(SonarDeleteUriTemplate, record.Id); + + sonarrRequest = new(HttpMethod.Delete, uriBuilder.Uri); + sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey); + + response = await _httpClient.SendAsync(sonarrRequest); + response.EnsureSuccessStatusCode(); + } + + if (queueResponse.Records.Count is 0) + { + break; + } + + if (totalRecords is 0) + { + totalRecords = queueResponse.TotalRecords; + } + + processedRecords += queueResponse.Records.Count; + + if (processedRecords >= totalRecords) + { + break; + } + + page++; + } while (processedRecords < totalRecords); + } + } +} \ No newline at end of file diff --git a/code/cleanuperr.sln b/code/cleanuperr.sln new file mode 100644 index 00000000..32aaf4e5 --- /dev/null +++ b/code/cleanuperr.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Executable", "Executable\Executable.csproj", "{38261017-0049-4377-B30F-7279CC2539B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{7AEA44C2-BDAA-49D8-A961-1FCA7779B39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{3FEC04CA-8CC5-49F5-BBDE-1581C7C8AFB5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{8871592A-B260-4B15-8EF8-6AB24480DE5D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {38261017-0049-4377-B30F-7279CC2539B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38261017-0049-4377-B30F-7279CC2539B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38261017-0049-4377-B30F-7279CC2539B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38261017-0049-4377-B30F-7279CC2539B2}.Release|Any CPU.Build.0 = Release|Any CPU + {7AEA44C2-BDAA-49D8-A961-1FCA7779B39B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7AEA44C2-BDAA-49D8-A961-1FCA7779B39B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AEA44C2-BDAA-49D8-A961-1FCA7779B39B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7AEA44C2-BDAA-49D8-A961-1FCA7779B39B}.Release|Any CPU.Build.0 = Release|Any CPU + {3FEC04CA-8CC5-49F5-BBDE-1581C7C8AFB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FEC04CA-8CC5-49F5-BBDE-1581C7C8AFB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FEC04CA-8CC5-49F5-BBDE-1581C7C8AFB5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FEC04CA-8CC5-49F5-BBDE-1581C7C8AFB5}.Release|Any CPU.Build.0 = Release|Any CPU + {8871592A-B260-4B15-8EF8-6AB24480DE5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8871592A-B260-4B15-8EF8-6AB24480DE5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8871592A-B260-4B15-8EF8-6AB24480DE5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8871592A-B260-4B15-8EF8-6AB24480DE5D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal