initial project commit

This commit is contained in:
Flaminel
2024-11-09 18:40:47 +02:00
parent 4a4f180874
commit 04961f6d04
21 changed files with 421 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+10
View File
@@ -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; }
}
@@ -0,0 +1,6 @@
namespace Common.Configuration;
public sealed class QuartzConfig
{
public required string FrozenTorrentTrigger { get; init; }
}
@@ -0,0 +1,6 @@
namespace Common.Configuration;
public sealed class SonarrConfig
{
public required List<SonarrInstance> Instances { get; set; }
}
@@ -0,0 +1,8 @@
namespace Common.Configuration;
public sealed class SonarrInstance
{
public required Uri Url { get; set; }
public required string ApiKey { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
namespace Domain.Sonarr.Queue;
public record CustomFormat(
int Id,
string Name
);
+6
View File
@@ -0,0 +1,6 @@
namespace Domain.Sonarr.Queue;
public record Language(
int Id,
string Name
);
@@ -0,0 +1,10 @@
namespace Domain.Sonarr.Queue;
public record QueueListResponse(
int Page,
int PageSize,
string SortKey,
string SortDirection,
int TotalRecords,
IReadOnlyList<Record> Records
);
+28
View File
@@ -0,0 +1,28 @@
namespace Domain.Sonarr.Queue;
public record Record(
int SeriesId,
int EpisodeId,
int SeasonNumber,
IReadOnlyList<Language> Languages,
IReadOnlyList<CustomFormat> CustomFormats,
int CustomFormatScore,
int Size,
string Title,
int 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
);
+7
View File
@@ -0,0 +1,7 @@
namespace Domain.Sonarr.Queue;
public record Revision(
int Version,
int Real,
bool IsRepack
);
@@ -0,0 +1,6 @@
namespace Domain.Sonarr.Queue;
public record StatusMessage(
string Title,
IReadOnlyList<string> Messages
);
+59
View File
@@ -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<QuartzConfig>(configuration.GetSection(nameof(QuartzConfig)))
.Configure<SonarrConfig>(configuration.GetSection(nameof(SonarrConfig)));
private static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddTransient<FrozenTorrentHandler>();
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
services
.AddQuartz(q =>
{
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
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<FrozenTorrentJob>(opts =>
{
opts.WithIdentity(nameof(FrozenTorrentJob));
});
q.AddTrigger(opts =>
{
opts.ForJob(nameof(FrozenTorrentJob))
.WithIdentity($"{nameof(FrozenTorrentJob)}-trigger")
.WithCronSchedule(trigger);
});
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0-rc.1.24431.7"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0-rc.2.24473.5" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>
</Project>
+29
View File
@@ -0,0 +1,29 @@
using Infrastructure.Verticals.FrozenTorrent;
using Quartz;
namespace Executable.Jobs;
[DisallowConcurrentExecution]
public sealed class FrozenTorrentJob : IJob
{
private ILogger<FrozenTorrentJob> _logger;
private FrozenTorrentHandler _handler;
public FrozenTorrentJob(ILogger<FrozenTorrentJob> 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");
}
}
}
+9
View File
@@ -0,0 +1,9 @@
using Executable;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
var host = builder.Build();
host.Run();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
+23
View File
@@ -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": ""
}
]
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
</ItemGroup>
</Project>
@@ -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<FrozenTorrentHandler> _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<FrozenTorrentHandler> 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;
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<QueueListResponse>(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);
}
}
}
+34
View File
@@ -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