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