mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-23 22:18:39 -05:00
Add queue rules (#332)
This commit is contained in:
@@ -19,11 +19,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -79,7 +79,7 @@ public static class ApiDI
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
@@ -12,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
@@ -19,7 +16,6 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -55,6 +51,9 @@ public static class ServicesDI
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record ArrInstanceRequest
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
};
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
{
|
||||
instance.Enabled = Enabled;
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ApiKey = ApiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateArrConfigRequest
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
|
||||
|
||||
[HttpGet("radarr")]
|
||||
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
|
||||
|
||||
[HttpGet("lidarr")]
|
||||
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
|
||||
|
||||
[HttpGet("readarr")]
|
||||
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
|
||||
|
||||
[HttpGet("whisparr")]
|
||||
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("radarr")]
|
||||
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("lidarr")]
|
||||
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("readarr")]
|
||||
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("whisparr")]
|
||||
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPost("sonarr/instances")]
|
||||
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
|
||||
|
||||
[HttpDelete("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteSonarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Sonarr, id);
|
||||
|
||||
[HttpPost("radarr/instances")]
|
||||
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("radarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Radarr, id, request);
|
||||
|
||||
[HttpDelete("radarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteRadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Radarr, id);
|
||||
|
||||
[HttpPost("lidarr/instances")]
|
||||
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
|
||||
|
||||
[HttpDelete("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteLidarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Lidarr, id);
|
||||
|
||||
[HttpPost("readarr/instances")]
|
||||
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("readarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Readarr, id, request);
|
||||
|
||||
[HttpDelete("readarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteReadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Readarr, id);
|
||||
|
||||
[HttpPost("whisparr/instances")]
|
||||
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPut("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
|
||||
|
||||
[HttpDelete("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Message = $"{type} configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save {Type} configuration", type);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = request.ToEntity(config.Id);
|
||||
await _dataContext.ArrInstances.AddAsync(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create {Type} instance", type);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
request.ApplyTo(instance);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
InstanceType.Radarr => nameof(GetRadarrConfig),
|
||||
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||
_ => nameof(GetSonarrConfig),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
|
||||
namespace Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string? BlacklistPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies the request to the provided configuration instance.
|
||||
/// </summary>
|
||||
public BlacklistSyncConfig ApplyTo(BlacklistSyncConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.BlacklistPath = BlacklistPath;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public bool HasPathChanged(string? currentPath)
|
||||
=> !string.Equals(currentPath, BlacklistPath, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class BlacklistSyncConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<BlacklistSyncConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public BlacklistSyncConfigController(
|
||||
ILogger<BlacklistSyncConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("blacklist_sync")]
|
||||
public async Task<IActionResult> GetBlacklistSyncConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("blacklist_sync")]
|
||||
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] UpdateBlacklistSyncConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool enabledChanged = config.Enabled != request.Enabled;
|
||||
bool becameEnabled = !config.Enabled && request.Enabled;
|
||||
bool pathChanged = request.HasPathChanged(config.BlacklistPath);
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
if (enabledChanged)
|
||||
{
|
||||
if (becameEnabled)
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
|
||||
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, config.CronExpression);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
|
||||
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
}
|
||||
else if (pathChanged && config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("BlacklistSynchronizer path changed");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
|
||||
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0 0 * * * ?";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule.
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public List<CleanCategoryRequest> Categories { get; init; } = [];
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled.
|
||||
/// </summary>
|
||||
public bool UnlinkedEnabled { get; init; }
|
||||
|
||||
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UnlinkedUseTag { get; init; }
|
||||
|
||||
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
|
||||
|
||||
public List<string> UnlinkedCategories { get; init; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
}
|
||||
|
||||
public record CleanCategoryRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadCleanerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public DownloadCleanerConfigController(
|
||||
ILogger<DownloadCleanerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("download_cleaner")]
|
||||
public async Task<IActionResult> GetDownloadCleanerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("download_cleaner")]
|
||||
public async Task<IActionResult> UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigRequest newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (newConfigDto is null)
|
||||
{
|
||||
throw new ValidationException("Request body cannot be null");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
if (newConfigDto.Enabled && newConfigDto.Categories.Any())
|
||||
{
|
||||
if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
|
||||
{
|
||||
throw new ValidationException("Duplicate category names found");
|
||||
}
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(categoryDto.Name))
|
||||
{
|
||||
throw new ValidationException("Category name cannot be empty");
|
||||
}
|
||||
|
||||
if (categoryDto is { MaxRatio: < 0, MaxSeedTime: < 0 })
|
||||
{
|
||||
throw new ValidationException("Either max ratio or max seed time must be enabled");
|
||||
}
|
||||
|
||||
if (categoryDto.MinSeedTime < 0)
|
||||
{
|
||||
throw new ValidationException("Min seed time cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("Unlinked target category cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories?.Count is null or 0)
|
||||
{
|
||||
throw new ValidationException("Unlinked categories cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Contains(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Any(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
throw new ValidationException("Empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(newConfigDto.UnlinkedIgnoredRootDir) && !Directory.Exists(newConfigDto.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
throw new ValidationException($"{newConfigDto.UnlinkedIgnoredRootDir} root directory does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.FirstAsync();
|
||||
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
_dataContext.CleanCategories.Add(new CleanCategory
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
DownloadCleanerConfigId = oldConfig.Id
|
||||
});
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
|
||||
|
||||
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record CreateDownloadClientRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToEntity() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateDownloadClientRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
|
||||
public DownloadClientController(
|
||||
ILogger<DownloadClientController> logger,
|
||||
DataContext dataContext,
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_dynamicHttpClientFactory = dynamicHttpClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("download_client")]
|
||||
public async Task<IActionResult> GetDownloadClientConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var clients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
clients = clients
|
||||
.OrderBy(c => c.TypeName)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { clients });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("download_client")]
|
||||
public async Task<IActionResult> CreateDownloadClientConfig([FromBody] CreateDownloadClientRequest newClient)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
newClient.Validate();
|
||||
|
||||
var clientConfig = newClient.ToEntity();
|
||||
|
||||
_dataContext.DownloadClients.Add(clientConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download client");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("download_client/{id}")]
|
||||
public async Task<IActionResult> UpdateDownloadClientConfig(Guid id, [FromBody] UpdateDownloadClientRequest updatedClient)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
updatedClient.Validate();
|
||||
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {id} not found");
|
||||
}
|
||||
|
||||
var clientToPersist = updatedClient.ApplyTo(existingClient);
|
||||
|
||||
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(clientToPersist);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("download_client/{id}")]
|
||||
public async Task<IActionResult> DeleteDownloadClientConfig(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {id} not found");
|
||||
}
|
||||
|
||||
_dataContext.DownloadClients.Remove(existingClient);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var clientName = $"DownloadClient_{id}";
|
||||
_dynamicHttpClientFactory.UnregisterConfiguration(clientName);
|
||||
|
||||
_logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateGeneralConfigRequest
|
||||
{
|
||||
public bool DisplaySupportBanner { get; init; } = true;
|
||||
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
public ushort HttpMaxRetries { get; init; }
|
||||
|
||||
public ushort HttpTimeout { get; init; } = 100;
|
||||
|
||||
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchDelay { get; init; } = 30;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||
|
||||
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
|
||||
{
|
||||
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
|
||||
existingConfig.DryRun = DryRun;
|
||||
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||
existingConfig.HttpTimeout = HttpTimeout;
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.SearchEnabled = SearchEnabled;
|
||||
existingConfig.SearchDelay = SearchDelay;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||
|
||||
Validate(existingConfig);
|
||||
|
||||
ApplySideEffects(existingConfig, services, logger, loggingChanged);
|
||||
|
||||
return existingConfig;
|
||||
}
|
||||
|
||||
private static void Validate(GeneralConfig config)
|
||||
{
|
||||
if (config.HttpTimeout is 0)
|
||||
{
|
||||
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
|
||||
}
|
||||
|
||||
config.Log.Validate();
|
||||
}
|
||||
|
||||
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
|
||||
{
|
||||
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
|
||||
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(config);
|
||||
|
||||
logger.LogInformation("Updated all HTTP client configurations with new general settings");
|
||||
|
||||
if (!loggingChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Log.LevelOnlyChange)
|
||||
{
|
||||
logger.LogCritical("Setting global log level to {level}", config.Log.Level);
|
||||
LoggingConfigManager.SetLogLevel(config.Log.Level);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("Reconfiguring logger due to configuration changes");
|
||||
LoggingConfigManager.ReconfigureLogging(config);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateLoggingConfigRequest
|
||||
{
|
||||
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
|
||||
|
||||
public ushort RollingSizeMB { get; init; } = 10;
|
||||
|
||||
public ushort RetainedFileCount { get; init; } = 5;
|
||||
|
||||
public ushort TimeLimitHours { get; init; } = 24;
|
||||
|
||||
public bool ArchiveEnabled { get; init; } = true;
|
||||
|
||||
public ushort ArchiveRetainedCount { get; init; } = 60;
|
||||
|
||||
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
|
||||
|
||||
public bool ApplyTo(LoggingConfig existingConfig)
|
||||
{
|
||||
bool levelChanged = existingConfig.Level != Level;
|
||||
bool otherPropertiesChanged =
|
||||
existingConfig.RollingSizeMB != RollingSizeMB ||
|
||||
existingConfig.RetainedFileCount != RetainedFileCount ||
|
||||
existingConfig.TimeLimitHours != TimeLimitHours ||
|
||||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
|
||||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
|
||||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Level = Level;
|
||||
existingConfig.RollingSizeMB = RollingSizeMB;
|
||||
existingConfig.RetainedFileCount = RetainedFileCount;
|
||||
existingConfig.TimeLimitHours = TimeLimitHours;
|
||||
existingConfig.ArchiveEnabled = ArchiveEnabled;
|
||||
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
|
||||
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Validate();
|
||||
|
||||
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
|
||||
|
||||
return levelChanged || otherPropertiesChanged;
|
||||
}
|
||||
|
||||
public bool LevelOnlyChange { get; private set; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class GeneralConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<GeneralConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly MemoryCache _cache;
|
||||
|
||||
public GeneralConfigController(
|
||||
ILogger<GeneralConfigController> logger,
|
||||
DataContext dataContext,
|
||||
MemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet("general")]
|
||||
public async Task<IActionResult> GetGeneralConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.GeneralConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("general")]
|
||||
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.GeneralConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool wasDryRun = config.DryRun;
|
||||
|
||||
request.ApplyTo(config, HttpContext.RequestServices, _logger);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
|
||||
|
||||
return Ok(new { Message = "General configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save General configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
|
||||
{
|
||||
if (!wasDryRun || isDryRun)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<object> keys;
|
||||
|
||||
// Remove strikes
|
||||
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
|
||||
{
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
|
||||
}
|
||||
|
||||
// Remove strike cache items
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0/5 * * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public bool DeleteKnownMalware { get; init; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Radarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Lidarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Readarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Whisparr { get; init; } = new();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.CronExpression = CronExpression;
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
config.DeletePrivate = DeletePrivate;
|
||||
config.DeleteKnownMalware = DeleteKnownMalware;
|
||||
config.Sonarr = Sonarr;
|
||||
config.Radarr = Radarr;
|
||||
config.Lidarr = Lidarr;
|
||||
config.Readarr = Readarr;
|
||||
config.Whisparr = Whisparr;
|
||||
config.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MalwareBlockerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public MalwareBlockerConfigController(
|
||||
ILogger<MalwareBlockerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("malware_blocker")]
|
||||
public async Task<IActionResult> GetMalwareBlockerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("malware_blocker")]
|
||||
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] UpdateMalwareBlockerConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(request.CronExpression, JobType.MalwareBlocker);
|
||||
}
|
||||
|
||||
var config = await _dataContext.ContentBlockerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(config, JobType.MalwareBlocker);
|
||||
|
||||
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateNotifiarrProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateNtfyProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestAppriseProviderRequest
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestNotifiarrProviderRequest
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestNtfyProviderRequest
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateNotifiarrProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateNtfyProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
|
||||
public sealed record NotificationProviderResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public NotificationProviderType Type { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public NotificationEventFlags Events { get; init; } = new();
|
||||
|
||||
public object Configuration { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
|
||||
public sealed record NotificationProvidersResponse
|
||||
{
|
||||
public List<NotificationProviderResponse> Providers { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration/notification_providers")]
|
||||
public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<NotificationProvidersController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly INotificationConfigurationService _notificationConfigurationService;
|
||||
private readonly NotificationService _notificationService;
|
||||
|
||||
public NotificationProvidersController(
|
||||
ILogger<NotificationProvidersController> logger,
|
||||
DataContext dataContext,
|
||||
INotificationConfigurationService notificationConfigurationService,
|
||||
NotificationService notificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_notificationConfigurationService = notificationConfigurationService;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetNotificationProviders()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var providers = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var providerDtos = providers
|
||||
.Select(p => new NotificationProviderResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Type = p.Type,
|
||||
IsEnabled = p.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = p.OnFailedImportStrike,
|
||||
OnStalledStrike = p.OnStalledStrike,
|
||||
OnSlowStrike = p.OnSlowStrike,
|
||||
OnQueueItemDeleted = p.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = p.OnDownloadCleaned,
|
||||
OnCategoryChanged = p.OnCategoryChanged
|
||||
},
|
||||
Configuration = p.Type switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
.OrderBy(x => x.Type.ToString())
|
||||
.ThenBy(x => x.Name)
|
||||
.ToList();
|
||||
|
||||
var response = new NotificationProvidersResponse { Providers = providerDtos };
|
||||
return Ok(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notifiarr")]
|
||||
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = newProvider.ApiKey,
|
||||
ChannelId = newProvider.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
NotifiarrConfiguration = notifiarrConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Notifiarr provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("apprise")]
|
||||
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = newProvider.Url,
|
||||
Key = newProvider.Key,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
AppriseConfiguration = appriseConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Apprise provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ntfy")]
|
||||
public async Task<IActionResult> CreateNtfyProvider([FromBody] CreateNtfyProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
Topics = newProvider.Topics,
|
||||
AuthenticationType = newProvider.AuthenticationType,
|
||||
Username = newProvider.Username,
|
||||
Password = newProvider.Password,
|
||||
AccessToken = newProvider.AccessToken,
|
||||
Priority = newProvider.Priority,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Ntfy provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("notifiarr/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Notifiarr provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = updatedProvider.ApiKey,
|
||||
ChannelId = updatedProvider.ChannelId
|
||||
};
|
||||
|
||||
if (existingProvider.NotifiarrConfiguration != null)
|
||||
{
|
||||
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
|
||||
}
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
NotifiarrConfiguration = notifiarrConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("apprise/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Apprise provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = updatedProvider.Url,
|
||||
Key = updatedProvider.Key,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.AppriseConfiguration != null)
|
||||
{
|
||||
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
|
||||
}
|
||||
appriseConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
AppriseConfiguration = appriseConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("ntfy/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Ntfy provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
Topics = updatedProvider.Topics,
|
||||
AuthenticationType = updatedProvider.AuthenticationType,
|
||||
Username = updatedProvider.Username,
|
||||
Password = updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken,
|
||||
Priority = updatedProvider.Priority,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.NtfyConfiguration != null)
|
||||
{
|
||||
ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id };
|
||||
}
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Notification provider with ID {id} not found");
|
||||
}
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
|
||||
existingProvider.Name, existingProvider.Id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notifiarr/test")]
|
||||
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = testRequest.ApiKey,
|
||||
ChannelId = testRequest.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = notifiarrConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Notifiarr provider");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("apprise/test")]
|
||||
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = testRequest.Url,
|
||||
Key = testRequest.Key,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = appriseConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Apprise provider");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ntfy/test")]
|
||||
public async Task<IActionResult> TestNtfyProvider([FromBody] TestNtfyProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
Topics = testRequest.Topics,
|
||||
AuthenticationType = testRequest.AuthenticationType,
|
||||
Username = testRequest.Username,
|
||||
Password = testRequest.Password,
|
||||
AccessToken = testRequest.AccessToken,
|
||||
Priority = testRequest.Priority,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = ntfyConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Ntfy provider");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationProviderResponse MapProvider(NotificationConfig provider)
|
||||
{
|
||||
return new NotificationProviderResponse
|
||||
{
|
||||
Id = provider.Id,
|
||||
Name = provider.Name,
|
||||
Type = provider.Type,
|
||||
IsEnabled = provider.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = provider.OnFailedImportStrike,
|
||||
OnStalledStrike = provider.OnStalledStrike,
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnCategoryChanged = provider.OnCategoryChanged
|
||||
},
|
||||
Configuration = provider.Type switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public abstract record QueueRuleDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(3, int.MaxValue, ErrorMessage = "Max strikes must be at least 3")]
|
||||
public int MaxStrikes { get; set; } = 3;
|
||||
|
||||
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
|
||||
public ushort MinCompletionPercentage { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
|
||||
public ushort MaxCompletionPercentage { get; set; }
|
||||
|
||||
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record SlowRuleDto : QueueRuleDto
|
||||
{
|
||||
public bool ResetStrikesOnProgress { get; set; } = true;
|
||||
|
||||
public string MinSpeed { get; set; } = string.Empty;
|
||||
|
||||
[Range(0, double.MaxValue, ErrorMessage = "Maximum time cannot be negative")]
|
||||
public double MaxTimeHours { get; set; } = 0;
|
||||
|
||||
public string? IgnoreAboveSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record StallRuleDto : QueueRuleDto
|
||||
{
|
||||
public bool ResetStrikesOnProgress { get; set; } = true;
|
||||
|
||||
public string? MinimumProgress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0 0/5 * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public FailedImportConfig FailedImport { get; init; } = new();
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class QueueCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueCleanerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public QueueCleanerConfigController(
|
||||
ILogger<QueueCleanerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("queue_cleaner")]
|
||||
public async Task<IActionResult> GetQueueCleanerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.QueueCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("queue_cleaner")]
|
||||
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] UpdateQueueCleanerConfigRequest newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
newConfigDto.FailedImport.Validate();
|
||||
|
||||
var oldConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.QueueCleaner);
|
||||
|
||||
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/queue-rules")]
|
||||
public class QueueRulesController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueRulesController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRuleIntervalValidator _ruleIntervalValidator;
|
||||
|
||||
public QueueRulesController(
|
||||
ILogger<QueueRulesController> logger,
|
||||
DataContext dataContext,
|
||||
IRuleIntervalValidator ruleIntervalValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_ruleIntervalValidator = ruleIntervalValidator;
|
||||
}
|
||||
|
||||
[HttpGet("stall")]
|
||||
public async Task<IActionResult> GetStallRules()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var rules = await _dataContext.StallRules
|
||||
.OrderBy(r => r.MinCompletionPercentage)
|
||||
.ThenBy(r => r.Name)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve stall rules");
|
||||
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("stall")]
|
||||
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (existingRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A stall rule with this name already exists" });
|
||||
}
|
||||
|
||||
var rule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id,
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.StallRules.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
rule.Validate();
|
||||
|
||||
_dataContext.StallRules.Add(rule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created stall rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
|
||||
|
||||
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
|
||||
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("stall/{id}")]
|
||||
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
var duplicateRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (duplicateRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A stall rule with this name already exists" });
|
||||
}
|
||||
|
||||
var updatedRule = existingRule with
|
||||
{
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.StallRules
|
||||
.Where(r => r.Id != id)
|
||||
.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
updatedRule.Validate();
|
||||
|
||||
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated stall rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
|
||||
|
||||
return Ok(updatedRule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("stall/{id}")]
|
||||
public async Task<IActionResult> DeleteStallRule(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
_dataContext.StallRules.Remove(existingRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted stall rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("slow")]
|
||||
public async Task<IActionResult> GetSlowRules()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var rules = await _dataContext.SlowRules
|
||||
.OrderBy(r => r.MinCompletionPercentage)
|
||||
.ThenBy(r => r.Name)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve slow rules");
|
||||
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("slow")]
|
||||
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (existingRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A slow rule with this name already exists" });
|
||||
}
|
||||
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id,
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
rule.Validate();
|
||||
|
||||
_dataContext.SlowRules.Add(rule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created slow rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
|
||||
|
||||
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
|
||||
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("slow/{id}")]
|
||||
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
var duplicateRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (duplicateRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A slow rule with this name already exists" });
|
||||
}
|
||||
|
||||
var updatedRule = existingRule with
|
||||
{
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules
|
||||
.Where(r => r.Id != id)
|
||||
.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
updatedRule.Validate();
|
||||
|
||||
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated slow rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
|
||||
|
||||
return Ok(updatedRule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("slow/{id}")]
|
||||
public async Task<IActionResult> DeleteSlowRule(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
_dataContext.SlowRules.Remove(existingRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted slow rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateAppriseProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
using CreateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest instead.")]
|
||||
public sealed record CreateAppriseProviderDto : CreateAppriseProviderRequest;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateNotifiarrProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
using CreateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest instead.")]
|
||||
public sealed record CreateNotifiarrProviderDto : CreateNotifiarrProviderRequest;
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public abstract record CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
using CreateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest instead.")]
|
||||
public sealed record CreateNtfyProviderDto : CreateNtfyProviderRequest;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestAppriseProviderDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
using TestAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest instead.")]
|
||||
public sealed record TestAppriseProviderDto : TestAppriseProviderRequest;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestNotifiarrProviderDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
using TestNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest instead.")]
|
||||
public sealed record TestNotifiarrProviderDto : TestNotifiarrProviderRequest;
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestNtfyProviderDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
using TestNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest instead.")]
|
||||
public sealed record TestNtfyProviderDto : TestNtfyProviderRequest;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateAppriseProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
using UpdateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest instead.")]
|
||||
public sealed record UpdateAppriseProviderDto : UpdateAppriseProviderRequest;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateNotifiarrProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
using UpdateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest instead.")]
|
||||
public sealed record UpdateNotifiarrProviderDto : UpdateNotifiarrProviderRequest;
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
using UpdateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest instead.")]
|
||||
public sealed record UpdateNtfyProviderDto : UpdateNtfyProviderRequest;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1,55 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
namespace Cleanuparr.Api.Models;
|
||||
|
||||
public class UpdateDownloadCleanerConfigDto
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
|
||||
|
||||
public string CronExpression { get; set; } = "0 0 * * * ?";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; set; }
|
||||
|
||||
public List<CleanCategoryDto> Categories { get; set; } = [];
|
||||
|
||||
public bool DeletePrivate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled
|
||||
/// </summary>
|
||||
public bool UnlinkedEnabled { get; set; } = false;
|
||||
|
||||
public string UnlinkedTargetCategory { get; set; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UnlinkedUseTag { get; set; }
|
||||
|
||||
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
|
||||
|
||||
public List<string> UnlinkedCategories { get; set; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CleanCategoryDto
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; set; } = -1;
|
||||
}
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record CleanCategoryDto : CleanCategoryRequest;
|
||||
@@ -150,4 +150,7 @@ app.ConfigureApi();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
await Log.CloseAndFlushAsync();
|
||||
await Log.CloseAndFlushAsync();
|
||||
|
||||
// Make Program class accessible for testing
|
||||
public partial class Program { }
|
||||
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Domain\Cleanuparr.Domain.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Persistence\Cleanuparr.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,66 +0,0 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
|
||||
namespace Cleanuparr.Application.Features.DownloadClient.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for creating a new download client (without ID)
|
||||
/// </summary>
|
||||
public sealed record CreateDownloadClientDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this client is enabled
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly name for this client
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type name of download client
|
||||
/// </summary>
|
||||
public required DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of download client
|
||||
/// </summary>
|
||||
public required DownloadClientType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host address for the download client
|
||||
/// </summary>
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Username for authentication
|
||||
/// </summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for authentication
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The base URL path component, used by clients like Transmission and Deluge
|
||||
/// </summary>
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
57
code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs
Normal file
57
code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace Cleanuparr.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Universal abstraction for a torrent item across all download clients.
|
||||
/// Provides a unified interface for accessing torrent properties and state.
|
||||
/// </summary>
|
||||
public interface ITorrentItem
|
||||
{
|
||||
// Basic identification
|
||||
string Hash { get; }
|
||||
string Name { get; }
|
||||
|
||||
// Privacy and tracking
|
||||
bool IsPrivate { get; }
|
||||
IReadOnlyList<string> Trackers { get; }
|
||||
|
||||
// Size and progress
|
||||
long Size { get; }
|
||||
double CompletionPercentage { get; }
|
||||
long DownloadedBytes { get; }
|
||||
long TotalUploaded { get; }
|
||||
|
||||
// Speed and transfer rates
|
||||
long DownloadSpeed { get; }
|
||||
long UploadSpeed { get; }
|
||||
double Ratio { get; }
|
||||
|
||||
// Time tracking
|
||||
long Eta { get; }
|
||||
DateTime? DateAdded { get; }
|
||||
DateTime? DateCompleted { get; }
|
||||
long SeedingTimeSeconds { get; }
|
||||
|
||||
// Categories and tags
|
||||
string? Category { get; }
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
|
||||
// State checking methods
|
||||
bool IsDownloading();
|
||||
bool IsStalled();
|
||||
bool IsSeeding();
|
||||
bool IsCompleted();
|
||||
bool IsPaused();
|
||||
bool IsQueued();
|
||||
bool IsChecking();
|
||||
bool IsAllocating();
|
||||
bool IsMetadataDownloading();
|
||||
|
||||
// Filtering methods
|
||||
/// <summary>
|
||||
/// Determines if this torrent should be ignored based on the provided patterns.
|
||||
/// Checks if any pattern matches the torrent name, hash, or tracker.
|
||||
/// </summary>
|
||||
/// <param name="ignoredDownloads">List of patterns to check against</param>
|
||||
/// <returns>True if the torrent matches any ignore pattern</returns>
|
||||
bool IsIgnored(IReadOnlyList<string> ignoredDownloads);
|
||||
}
|
||||
11
code/backend/Cleanuparr.Domain/Enums/TorrentPrivacyType.cs
Normal file
11
code/backend/Cleanuparr.Domain/Enums/TorrentPrivacyType.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TorrentPrivacyType
|
||||
{
|
||||
Public,
|
||||
Private,
|
||||
Both
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = expectedHash,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = expectedName,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Private = true,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Size = expectedSize,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1024, 0.0)]
|
||||
[InlineData(512, 1024, 50.0)]
|
||||
[InlineData(768, 1024, 75.0)]
|
||||
[InlineData(1024, 1024, 100.0)]
|
||||
[InlineData(0, 0, 0.0)] // Edge case: zero size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Size = size,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null! }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = null!,
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using QBittorrent.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class QBitItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var trackers = new List<TorrentTracker>();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullTrackers_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { Hash = expectedHash };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Hash = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, true);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var torrentInfo = new TorrentInfo { Size = expectedSize };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_WithZeroValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Size = 0 };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(0.5, 50.0)]
|
||||
[InlineData(0.75, 75.0)]
|
||||
[InlineData(1.0, 100.0)]
|
||||
public void CompletionPercentage_ReturnsCorrectValue(double progress, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// State checking method tests
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Downloading, true)]
|
||||
[InlineData(TorrentState.ForcedDownload, true)]
|
||||
[InlineData(TorrentState.StalledDownload, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
[InlineData(TorrentState.PausedDownload, false)]
|
||||
public void IsDownloading_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsDownloading();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.StalledDownload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.ForcedDownload, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsStalled_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsStalled();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Uploading, true)]
|
||||
[InlineData(TorrentState.ForcedUpload, true)]
|
||||
[InlineData(TorrentState.StalledUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.PausedUpload, false)]
|
||||
public void IsSeeding_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsSeeding();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.5, false)]
|
||||
[InlineData(0.99, false)]
|
||||
[InlineData(1.0, true)]
|
||||
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsCompleted();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.PausedDownload, true)]
|
||||
[InlineData(TorrentState.PausedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPaused();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.QueuedDownload, true)]
|
||||
[InlineData(TorrentState.QueuedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsQueued();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.CheckingDownload, true)]
|
||||
[InlineData(TorrentState.CheckingUpload, true)]
|
||||
[InlineData(TorrentState.CheckingResumeData, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsChecking();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Allocating, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsAllocating();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.FetchingMetadata, true)]
|
||||
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.StalledDownload, false)]
|
||||
public void IsMetadataDownloading_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsMetadataDownloading();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingName_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "test" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Shouldly;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { HashString = expectedHash };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, 1024L, 0.0)]
|
||||
[InlineData(512L, 1024L, 50.0)]
|
||||
[InlineData(768L, 1024L, 75.0)]
|
||||
[InlineData(1024L, 1024L, 100.0)]
|
||||
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
|
||||
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
|
||||
[InlineData(512L, null, 0.0)] // Edge case: null total size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
DownloadedEver = downloadedEver,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker2.example.com/announce" },
|
||||
new() { Announce = "udp://tracker3.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker1.example.com/announce" },
|
||||
new() { Announce = "udp://tracker1.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://valid.example.com/announce" },
|
||||
new() { Announce = "invalid-url" },
|
||||
new() { Announce = "" },
|
||||
new() { Announce = null }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[0]
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = null
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentItem_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new UTorrentItemWrapper(null!, torrentProperties));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentProperties_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new UTorrentItemWrapper(torrentItem, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentItem = new UTorrentItem { Hash = expectedHash };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentItem = new UTorrentItem { Name = expectedName };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties { Pex = -1 }; // -1 means private torrent
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var torrentItem = new UTorrentItem { Size = expectedSize };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0.0)] // 0 permille = 0%
|
||||
[InlineData(500, 50.0)] // 500 permille = 50%
|
||||
[InlineData(750, 75.0)] // 750 permille = 75%
|
||||
[InlineData(1000, 100.0)] // 1000 permille = 100%
|
||||
public void CompletionPercentage_ReturnsCorrectValue(int progress, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Progress = progress };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = ""
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueRuleMatchTests
|
||||
{
|
||||
[Fact]
|
||||
public void StallRule_WithNonZeroMinCompletion_ShouldExcludeLowerBoundary()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Stall",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var torrentAtBoundary = CreateTorrent(isPrivate: false, completionPercentage: 20);
|
||||
var torrentAboveBoundary = CreateTorrent(isPrivate: false, completionPercentage: 20.1);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAtBoundary.Object));
|
||||
Assert.True(rule.MatchesTorrent(torrentAboveBoundary.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_WithZeroMinCompletion_ShouldIncludeZero()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Zero",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 20,
|
||||
};
|
||||
|
||||
var zeroTorrent = CreateTorrent(isPrivate: false, completionPercentage: 0);
|
||||
var midTorrent = CreateTorrent(isPrivate: false, completionPercentage: 10);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(zeroTorrent.Object));
|
||||
Assert.True(rule.MatchesTorrent(midTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_WithNonZeroMinCompletion_ShouldExcludeLowerBoundary()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Slow",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 90,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAtBoundary = CreateTorrent(isPrivate: false, completionPercentage: 40);
|
||||
var torrentAboveBoundary = CreateTorrent(isPrivate: false, completionPercentage: 40.5);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAtBoundary.Object));
|
||||
Assert.True(rule.MatchesTorrent(torrentAboveBoundary.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Both_MatchesPublic()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Both_MatchesPrivate()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Both_MatchesPublic()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Both_MatchesPrivate()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_AtMaxBoundary_Matches()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Max Boundary",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentAtMax = CreateTorrent(isPrivate: false, completionPercentage: 80);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(torrentAtMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_BelowMin_DoesNotMatch()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Below Min",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentBelowMin = CreateTorrent(isPrivate: false, completionPercentage: 15);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentBelowMin.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_AboveMax_DoesNotMatch()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Above Max",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentAboveMax = CreateTorrent(isPrivate: false, completionPercentage: 85);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAboveMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_AtMaxBoundary_Matches()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Max Boundary",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAtMax = CreateTorrent(isPrivate: false, completionPercentage: 70);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(torrentAtMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_BelowMin_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Below Min",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentBelowMin = CreateTorrent(isPrivate: false, completionPercentage: 25);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentBelowMin.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_AboveMax_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Above Max",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAboveMax = CreateTorrent(isPrivate: false, completionPercentage: 75);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAboveMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_IgnoreAboveSize_TorrentTooLarge_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Size Limited",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = "50 GB",
|
||||
};
|
||||
|
||||
var largeTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "100 GB");
|
||||
|
||||
Assert.False(rule.MatchesTorrent(largeTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_IgnoreAboveSize_TorrentSizeOk_Matches()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Size Limited",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = "50 GB",
|
||||
};
|
||||
|
||||
var smallTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "30 GB");
|
||||
|
||||
Assert.True(rule.MatchesTorrent(smallTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_NoIgnoreSizeSet_AllSizesMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "No Size Limit",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = string.Empty,
|
||||
};
|
||||
|
||||
var hugeTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "500 GB");
|
||||
|
||||
Assert.True(rule.MatchesTorrent(hugeTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Public_DoesNotMatchPrivate()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Public Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Private_DoesNotMatchPublic()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Private Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Public_DoesNotMatchPrivate()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Public Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Private_DoesNotMatchPublic()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Private Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
{
|
||||
var torrent = new Mock<ITorrentItem>();
|
||||
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = "Stall Rule",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
MinimumProgress = "10 MB",
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
long downloadedBytes = 0;
|
||||
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
|
||||
|
||||
// Seed cache with initial observation (no reset expected)
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Progress below threshold should not reset strikes
|
||||
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Progress beyond threshold should trigger reset
|
||||
downloadedBytes = ByteSize.Parse("12 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenStrikeThrows_ShouldThrowException()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WhenEtaIsAcceptable_ShouldResetStrikes()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowTime))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA is 0.5 hours, below the 1 hour threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenStrikeThrows_ShouldThrowException()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ThrowsAsync(new InvalidOperationException("slow fail"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithSpeedBasedRule_ShouldUseSlowSpeedStrikeType()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(
|
||||
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed),
|
||||
Times.Once);
|
||||
strikerMock.Verify(
|
||||
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithBothSpeedAndTimeConfigured_ShouldTreatAsSlowSpeed()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Both Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 2,
|
||||
minSpeed: "500 KB",
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithNeitherSpeedNorTimeConfigured_ShouldNotStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Fallback Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 1,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WhenSpeedIsAcceptable_ShouldResetSlowSpeedStrikes()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Reset",
|
||||
resetOnProgress: true,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowSpeed))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above 1 MB threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed No Reset",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time No Reset",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 4,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA below threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBased_BelowThreshold_ShouldStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Strike",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "5 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes); // Speed below 5 MB threshold
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBased_AboveThreshold_ShouldStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time Strike",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 5,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 1);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200); // 2 hours, above 1 hour threshold
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
|
||||
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
|
||||
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
// Progress made but reset disabled, so no reset
|
||||
downloadedBytes = ByteSize.Parse("60 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
long downloadedBytes = 0;
|
||||
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
|
||||
|
||||
// Seed cache
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Any progress should trigger reset when no minimum is set
|
||||
downloadedBytes = ByteSize.Parse("1 KB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
Func<long>? downloadedBytesFactory = null,
|
||||
bool isPrivate = false,
|
||||
string hash = "hash",
|
||||
string name = "Example Torrent",
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns(hash);
|
||||
torrentMock.SetupGet(t => t.Name).Returns(name);
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200);
|
||||
return torrentMock;
|
||||
}
|
||||
|
||||
private static StallRule CreateStallRule(string name, bool resetOnProgress, int maxStrikes, string? minimumProgress = null, bool deletePrivateTorrentsFromClient = false)
|
||||
{
|
||||
return new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = maxStrikes,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = resetOnProgress,
|
||||
MinimumProgress = minimumProgress,
|
||||
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
|
||||
};
|
||||
}
|
||||
|
||||
private static SlowRule CreateSlowRule(
|
||||
string name,
|
||||
bool resetOnProgress,
|
||||
int maxStrikes,
|
||||
string? minSpeed = null,
|
||||
double maxTimeHours = 1,
|
||||
bool deletePrivateTorrentsFromClient = false)
|
||||
{
|
||||
return new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = maxStrikes,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = resetOnProgress,
|
||||
MaxTimeHours = maxTimeHours,
|
||||
MinSpeed = minSpeed ?? string.Empty,
|
||||
IgnoreAboveSize = string.Empty,
|
||||
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowTime, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowTime, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRuleWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
"Speed Delete True",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "5 MB",
|
||||
maxTimeHours: 0,
|
||||
deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes);
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleIntervalValidatorTests
|
||||
{
|
||||
private readonly RuleIntervalValidator _validator;
|
||||
|
||||
public RuleIntervalValidatorTests()
|
||||
{
|
||||
var logger = Mock.Of<ILogger<RuleIntervalValidator>>();
|
||||
_validator = new RuleIntervalValidator(logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_ReturnsFailureWhenOverlapDetected()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
result.ErrorMessage.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsAdjacentRanges()
|
||||
{
|
||||
var firstRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 41,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsTouchingRanges()
|
||||
{
|
||||
var firstRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsZeroWidthIntervalInsideExistingRange()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ZeroWidth",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 30
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ReturnsFullGapWhenNoRules()
|
||||
{
|
||||
var gaps = _validator.FindGapsInCoverage(new List<StallRule>());
|
||||
|
||||
gaps.ShouldNotBeEmpty();
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldSatisfyAllConditions(
|
||||
gap => gap.Start.ShouldBe(0),
|
||||
gap => gap.End.ShouldBe(100)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_UsesMinimumBoundaries()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Partial",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 60,
|
||||
MaxCompletionPercentage = 90
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public && g.Start >= 40 && g.End <= 60);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(40);
|
||||
publicGap.End.ShouldBe(60);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSlowRuleIntervals_AllowsTouchingRanges()
|
||||
{
|
||||
var firstRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var newRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var result = _validator.ValidateSlowRuleIntervals(newRule, new List<SlowRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsOverlapWithBothRule()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Both Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Public New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSlowRuleIntervals_DetectsOverlap()
|
||||
{
|
||||
var existingRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 50,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var newRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var result = _validator.ValidateSlowRuleIntervals(newRule, new List<SlowRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenFullyCovered()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Lower",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 50
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
// No public gaps expected
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenBothRuleCoversAll()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "BothCoverage",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ClampsBounds()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "OutOfRange",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 150
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_IgnoresDisabledRules()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_IgnoresOverlapWhenSameRuleId()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = id,
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
// New rule uses the same Id as the existing rule; overlaps should be ignored
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = id,
|
||||
Name = "NewSameId",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
// Since both rules share the same Id, they are considered the same rule and should not be treated as overlapping
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsOverlapInBothPublicAndPrivate()
|
||||
{
|
||||
var existingPublic = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPublic",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 30
|
||||
};
|
||||
|
||||
var existingPrivate = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 15,
|
||||
MaxCompletionPercentage = 35
|
||||
};
|
||||
|
||||
// New rule applies to both privacy types and overlaps both existing rules
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "NewBoth",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 25
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingPublic, existingPrivate });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// Expect at least two overlap details (one for public, one for private)
|
||||
result.Details.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsPrivateOverlap()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "NewPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_SucceedsWithOnlyNewRuleEnabled()
|
||||
{
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "OnlyNew",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 90
|
||||
};
|
||||
|
||||
// No existing rules
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule>());
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsNonOverlappingUnsortedRules()
|
||||
{
|
||||
var r1 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "R1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 60,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var r2 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "R2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 10
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
// Pass existing rules in unsorted order to exercise sorting inside the validator
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { r1, r2 });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresInvalidIntervalsWhereMaxLessThanMin()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Invalid",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 80,
|
||||
MaxCompletionPercentage = 20
|
||||
}
|
||||
};
|
||||
|
||||
// Invalid interval should be ignored, resulting in full gap
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresDisabledRulesWhenCalculatingCoverage()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DoesNotReportSelfOverlapWhenEnablingDisabledRule()
|
||||
{
|
||||
var ruleId = Guid.NewGuid();
|
||||
|
||||
// Simulate existing disabled rule
|
||||
var disabledRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "Rule1",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Simulate another existing enabled rule
|
||||
var enabledRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Now we enable the first rule (same ID, but now enabled)
|
||||
var nowEnabledRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "Rule1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Validation should detect overlap with Rule2, not with itself
|
||||
var result = _validator.ValidateStallRuleIntervals(nowEnabledRule, new List<StallRule> { disabledRule, enabledRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// The error should mention Rule2, not Rule1
|
||||
result.ErrorMessage.ShouldContain("Rule2");
|
||||
result.ErrorMessage.ShouldNotContain("Rule1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_BothPrivacyTypeDoesNotSelfOverlap()
|
||||
{
|
||||
var ruleId = Guid.NewGuid();
|
||||
|
||||
// Create a single rule with Both privacy type
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "BothRule",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Validate with no existing rules - should pass
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule>());
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsMultipleOverlaps()
|
||||
{
|
||||
// Rule1 covers 0-80%
|
||||
var rule1 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
// Rule2 covers 80-100%
|
||||
var rule2 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 80,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Rule3 overlaps both Rule1 and Rule2 (0-90%)
|
||||
var rule3 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule3",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 90
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(rule3, new List<StallRule> { rule1, rule2 });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// Should detect overlaps with both Rule1 and Rule2
|
||||
result.Details.Count.ShouldBe(2);
|
||||
result.Details.ShouldContain("Rule1");
|
||||
result.Details.ShouldContain("Rule2");
|
||||
result.ErrorMessage.ShouldContain("Rule1");
|
||||
result.ErrorMessage.ShouldContain("Rule2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_NoRules_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule>());
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_OneMatch_ReturnsRule()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Test Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
Assert.Equal("Test Rule", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_MultipleMatches_ReturnsNull_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule1 = CreateStallRule("Rule 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
var stallRule2 = CreateStallRule("Rule 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule1, stallRule2 });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("multiple")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_DisabledRule_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Disabled Rule", enabled: false, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeMismatch_Public_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Public Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: true, completionPercentage: 50); // Private torrent
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeMismatch_Private_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Private Rule", enabled: true, privacyType: TorrentPrivacyType.Private, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50); // Public torrent
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPublic()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPrivate()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageBelowMin_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 10); // Below 20%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAboveMax_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 90); // Above 80%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAtMinBoundary_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 20.1); // Just above 20%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAtMaxBoundary_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 80); // Exactly at 80%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_NoRules_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule>());
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_OneMatch_ReturnsRule()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
Assert.Equal("Slow Rule", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_MultipleMatches_ReturnsNull_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule1 = CreateSlowRule("Slow 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
var slowRule2 = CreateSlowRule("Slow 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule1, slowRule2 });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("multiple")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_FileSizeAboveIgnoreThreshold_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "100 MB"); // Torrent is 100 MB, above 50 MB threshold
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_FileSizeBelowIgnoreThreshold_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "30 MB"); // Torrent is 30 MB, below 50 MB threshold
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_NoIgnoreSizeSet_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("No Size Limit", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: string.Empty);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "1 GB"); // Any size should match
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
bool isPrivate = false,
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(3600);
|
||||
return torrentMock;
|
||||
}
|
||||
|
||||
private static StallRule CreateStallRule(
|
||||
string name,
|
||||
bool enabled,
|
||||
TorrentPrivacyType privacyType,
|
||||
ushort minCompletion,
|
||||
ushort maxCompletion)
|
||||
{
|
||||
return new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = privacyType,
|
||||
MinCompletionPercentage = minCompletion,
|
||||
MaxCompletionPercentage = maxCompletion,
|
||||
ResetStrikesOnProgress = false,
|
||||
MinimumProgress = null,
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static SlowRule CreateSlowRule(
|
||||
string name,
|
||||
bool enabled,
|
||||
TorrentPrivacyType privacyType,
|
||||
ushort minCompletion,
|
||||
ushort maxCompletion,
|
||||
string? ignoreAboveSize = null)
|
||||
{
|
||||
return new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = privacyType,
|
||||
MinCompletionPercentage = minCompletion,
|
||||
MaxCompletionPercentage = maxCompletion,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB",
|
||||
IgnoreAboveSize = ignoreAboveSize ?? string.Empty,
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
|
||||
|
||||
public class UTorrentClientTests
|
||||
{
|
||||
private readonly UTorrentClient _client;
|
||||
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
|
||||
private readonly DownloadClientConfig _config;
|
||||
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
|
||||
private readonly Mock<IUTorrentHttpService> _mockHttpService;
|
||||
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
|
||||
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
|
||||
|
||||
public UTorrentClientTests()
|
||||
{
|
||||
_mockHttpHandler = new Mock<HttpMessageHandler>();
|
||||
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
|
||||
_mockHttpService = new Mock<IUTorrentHttpService>();
|
||||
_mockResponseParser = new Mock<IUTorrentResponseParser>();
|
||||
_mockLogger = new Mock<ILogger<UTorrentClient>>();
|
||||
|
||||
_config = new DownloadClientConfig
|
||||
{
|
||||
Name = "test",
|
||||
Type = DownloadClientType.Torrent,
|
||||
TypeName = DownloadClientTypeName.uTorrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "password"
|
||||
};
|
||||
|
||||
_client = new UTorrentClient(
|
||||
_config,
|
||||
_mockAuthenticator.Object,
|
||||
_mockHttpService.Object,
|
||||
_mockResponseParser.Object,
|
||||
_mockLogger.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
|
||||
new object[] // File 1
|
||||
{
|
||||
"test name",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 2
|
||||
{
|
||||
"Dir1/Dir11/test11.zipx",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 3
|
||||
{
|
||||
"Dir1/sample.txt",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Equal(3, files.Count);
|
||||
|
||||
Assert.Equal("test name", files[0].Name);
|
||||
Assert.Equal(2604L, files[0].Size);
|
||||
Assert.Equal(0L, files[0].Downloaded);
|
||||
Assert.Equal(2, files[0].Priority);
|
||||
Assert.Equal(0, files[0].Index);
|
||||
|
||||
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
|
||||
Assert.Equal(2604L, files[1].Size);
|
||||
Assert.Equal(0L, files[1].Downloaded);
|
||||
Assert.Equal(2, files[1].Priority);
|
||||
Assert.Equal(1, files[1].Index);
|
||||
|
||||
Assert.Equal("Dir1/sample.txt", files[2].Name);
|
||||
Assert.Equal(2604L, files[2].Size);
|
||||
Assert.Equal(0L, files[2].Downloaded);
|
||||
Assert.Equal(2, files[2].Priority);
|
||||
Assert.Equal(2, files[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = null
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
public class ArrConfigDto
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for creating new Arr instances without requiring an ID
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Lidarr configuration basic settings (instances managed separately)
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Radarr configuration basic settings (instances managed separately)
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Readarr configuration basic settings (instances managed separately)
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Sonarr configuration basic settings (instances managed separately)
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for Deluge DownloadStatus that implements ITorrentItem interface
|
||||
/// </summary>
|
||||
public sealed class DelugeItem : ITorrentItem
|
||||
{
|
||||
private readonly DownloadStatus _downloadStatus;
|
||||
|
||||
public DelugeItem(DownloadStatus downloadStatus)
|
||||
{
|
||||
_downloadStatus = downloadStatus ?? throw new ArgumentNullException(nameof(downloadStatus));
|
||||
}
|
||||
|
||||
// Basic identification
|
||||
public string Hash => _downloadStatus.Hash ?? string.Empty;
|
||||
public string Name => _downloadStatus.Name ?? string.Empty;
|
||||
|
||||
// Privacy and tracking
|
||||
public bool IsPrivate => _downloadStatus.Private;
|
||||
public IReadOnlyList<string> Trackers => _downloadStatus.Trackers?
|
||||
.Where(t => !string.IsNullOrEmpty(t.Url))
|
||||
.Select(t => ExtractHostFromUrl(t.Url!))
|
||||
.Where(host => !string.IsNullOrEmpty(host))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// Size and progress
|
||||
public long Size => _downloadStatus.Size;
|
||||
public double CompletionPercentage => _downloadStatus.Size > 0
|
||||
? (_downloadStatus.TotalDone / (double)_downloadStatus.Size) * 100.0
|
||||
: 0.0;
|
||||
public long DownloadedBytes => _downloadStatus.TotalDone;
|
||||
public long TotalUploaded => (long)(_downloadStatus.Ratio * _downloadStatus.TotalDone);
|
||||
|
||||
// Speed and transfer rates
|
||||
public long DownloadSpeed => _downloadStatus.DownloadSpeed;
|
||||
public long UploadSpeed => 0; // Deluge DownloadStatus doesn't expose upload speed
|
||||
public double Ratio => _downloadStatus.Ratio;
|
||||
|
||||
// Time tracking
|
||||
public long Eta => (long)_downloadStatus.Eta;
|
||||
public DateTime? DateAdded => null; // Deluge DownloadStatus doesn't expose date added
|
||||
public DateTime? DateCompleted => null; // Deluge DownloadStatus doesn't expose date completed
|
||||
public long SeedingTimeSeconds => _downloadStatus.SeedingTime;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _downloadStatus.Label;
|
||||
public IReadOnlyList<string> Tags => Array.Empty<string>(); // Deluge doesn't have tags
|
||||
|
||||
// State checking methods
|
||||
public bool IsDownloading() => _downloadStatus.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsStalled() => _downloadStatus.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && _downloadStatus.DownloadSpeed == 0 && _downloadStatus.Eta == 0;
|
||||
public bool IsSeeding() => _downloadStatus.State?.Equals("Seeding", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsCompleted() => CompletionPercentage >= 100.0;
|
||||
public bool IsPaused() => _downloadStatus.State?.Equals("Paused", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsQueued() => _downloadStatus.State?.Equals("Queued", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsChecking() => _downloadStatus.State?.Equals("Checking", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsAllocating() => _downloadStatus.State?.Equals("Allocating", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
public bool IsMetadataDownloading() => false; // Deluge doesn't have this state
|
||||
|
||||
// Filtering methods
|
||||
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (ignoredDownloads.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the host from a tracker URL
|
||||
/// </summary>
|
||||
private static string ExtractHostFromUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -26,11 +27,13 @@ public partial class DelugeService : DownloadService, IDelugeService
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
) : base(
|
||||
logger, cache,
|
||||
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
_client = new DelugeClient(downloadClientConfig, _httpClient);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -9,78 +10,80 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
public partial class DelugeService
|
||||
{
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
|
||||
{
|
||||
return (await _client.GetStatusForAllTorrents())
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
var downloads = await _client.GetStatusForAllTorrents();
|
||||
if (downloads is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return downloads
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.Cast<object>()
|
||||
.Select(x => (ITorrentItem)new DelugeItem(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads)
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
|
||||
.FirstOrDefault(x => x.Name.Equals(download.Category, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.Private)
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTime);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTimeSeconds);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
@@ -97,7 +100,7 @@ public partial class DelugeService
|
||||
: "MAX_SEED_TIME",
|
||||
download.Name
|
||||
);
|
||||
|
||||
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
@@ -116,42 +119,50 @@ public partial class DelugeService
|
||||
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads.Cast<DownloadStatus>())
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Category))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
|
||||
// Get the underlying DownloadStatus to access DownloadLocation
|
||||
DownloadStatus? downloadStatus = await _client.GetTorrentStatus(download.Hash);
|
||||
if (downloadStatus is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent status for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
DelugeContents? contents = null;
|
||||
try
|
||||
{
|
||||
@@ -162,48 +173,46 @@ public partial class DelugeService
|
||||
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
|
||||
ProcessFiles(contents?.Contents, (_, file) =>
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/']));
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(downloadStatus.DownloadLocation, file.Path).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
long hardlinkCount = _hardLinkFileService
|
||||
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Label, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.Label = downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -29,10 +30,13 @@ public partial class DelugeService
|
||||
|
||||
result.Found = true;
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
var torrentItem = new DelugeItem(download);
|
||||
|
||||
if (torrentItem.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
_logger.LogInformation("skip | download is ignored | {name}", torrentItem.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -42,7 +46,7 @@ public partial class DelugeService
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrentItem.Name);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,110 +63,56 @@ public partial class DelugeService
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
_logger.LogTrace("all files are unwanted | removing download | {name}", download.Name);
|
||||
_logger.LogTrace("all files are unwanted | removing download | {name}", torrentItem.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download);
|
||||
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(DownloadStatus status)
|
||||
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(status);
|
||||
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(torrentItem);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(status);
|
||||
return await CheckIfStuck(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(DownloadStatus download)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogTrace("skip slow check | item is in {state} state | {name}", download.State, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Slow.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.Hash!,
|
||||
download.Name!,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(DownloadStatus status)
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
if (!torrentItem.IsDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Stalled.IgnorePrivate && status.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", status.State, status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
if (torrentItem.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(status.Hash!, status.TotalDone);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(status.Hash!, status.Name!, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
|
||||
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
|
||||
{
|
||||
if (!torrentItem.IsStalled())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,18 @@ public sealed record DownloadCheckResult
|
||||
/// True if the download should be removed; otherwise false.
|
||||
/// </summary>
|
||||
public bool ShouldRemove { get; set; }
|
||||
|
||||
|
||||
public DeleteReason DeleteReason { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True if the download is private; otherwise false.
|
||||
/// </summary>
|
||||
public bool IsPrivate { get; set; }
|
||||
|
||||
|
||||
public bool Found { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the download should be deleted from the client; otherwise false.
|
||||
/// </summary>
|
||||
public bool DeleteFromClient { get; set; }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
@@ -38,7 +39,9 @@ public abstract class DownloadService : IDownloadService
|
||||
protected readonly BlocklistProvider _blocklistProvider;
|
||||
protected readonly HttpClient _httpClient;
|
||||
protected readonly DownloadClientConfig _downloadClientConfig;
|
||||
|
||||
protected readonly IRuleEvaluator _ruleEvaluator;
|
||||
protected readonly IRuleManager _ruleManager;
|
||||
|
||||
protected DownloadService(
|
||||
ILogger<DownloadService> logger,
|
||||
IMemoryCache cache,
|
||||
@@ -49,7 +52,9 @@ public abstract class DownloadService : IDownloadService
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -64,6 +69,8 @@ public abstract class DownloadService : IDownloadService
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
_downloadClientConfig = downloadClientConfig;
|
||||
_httpClient = httpClientProvider.CreateClient(downloadClientConfig);
|
||||
_ruleEvaluator = ruleEvaluator;
|
||||
_ruleManager = ruleManager;
|
||||
}
|
||||
|
||||
public DownloadClientConfig ClientConfig => _downloadClientConfig;
|
||||
@@ -81,19 +88,19 @@ public abstract class DownloadService : IDownloadService
|
||||
public abstract Task DeleteDownload(string hash);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<List<object>?> GetSeedingDownloads();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||
public abstract Task<List<ITorrentItem>?> GetSeedingDownloads();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||
public abstract List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
public abstract List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
public abstract Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CreateCategoryAsync(string name);
|
||||
@@ -101,114 +108,6 @@ public abstract class DownloadService : IDownloadService
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (!queueCleanerConfig.Stalled.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(CacheKeys.StrikeItem(hash, StrikeType.Stalled), out StalledCacheItem? cachedItem) &&
|
||||
cachedItem is not null && downloaded > cachedItem.Downloaded)
|
||||
{
|
||||
// cache item found
|
||||
_cache.Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
|
||||
_logger.LogDebug("resetting stalled strikes for {hash} due to progress", hash);
|
||||
}
|
||||
|
||||
_cache.Set(CacheKeys.StrikeItem(hash, StrikeType.Stalled), new StalledCacheItem { Downloaded = downloaded }, _cacheOptions);
|
||||
}
|
||||
|
||||
protected void ResetSlowSpeedStrikesOnProgress(string downloadName, string hash)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = CacheKeys.Strike(StrikeType.SlowSpeed, hash);
|
||||
|
||||
if (!_cache.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Remove(key);
|
||||
_logger.LogDebug("resetting slow speed strikes due to progress | {name}", downloadName);
|
||||
}
|
||||
|
||||
protected void ResetSlowTimeStrikesOnProgress(string downloadName, string hash)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = CacheKeys.Strike(StrikeType.SlowTime, hash);
|
||||
|
||||
if (!_cache.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Remove(key);
|
||||
_logger.LogDebug("resetting slow time strikes due to progress | {name}", downloadName);
|
||||
}
|
||||
|
||||
protected async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(
|
||||
string downloadHash,
|
||||
string downloadName,
|
||||
ByteSize minSpeed,
|
||||
ByteSize currentSpeed,
|
||||
SmartTimeSpan maxTime,
|
||||
SmartTimeSpan currentTime
|
||||
)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (minSpeed.Bytes > 0 && currentSpeed < minSpeed)
|
||||
{
|
||||
_logger.LogTrace("slow speed | {speed}/s | {name}", currentSpeed.ToString(), downloadName);
|
||||
|
||||
bool shouldRemove = await _striker
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, queueCleanerConfig.Slow.MaxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
return (true, DeleteReason.SlowSpeed);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetSlowSpeedStrikesOnProgress(downloadName, downloadHash);
|
||||
}
|
||||
|
||||
if (maxTime.Time > TimeSpan.Zero && currentTime > maxTime)
|
||||
{
|
||||
_logger.LogTrace("slow estimated time | {time} | {name}", currentTime.ToString(), downloadName);
|
||||
|
||||
bool shouldRemove = await _striker
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, queueCleanerConfig.Slow.MaxStrikes, StrikeType.SlowTime);
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
return (true, DeleteReason.SlowTime);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetSlowTimeStrikesOnProgress(downloadName, downloadHash);
|
||||
}
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category)
|
||||
{
|
||||
// check ratio
|
||||
|
||||
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -67,13 +68,16 @@ public sealed class DownloadServiceFactory
|
||||
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
|
||||
|
||||
|
||||
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
|
||||
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
|
||||
|
||||
// Create the QBitService instance
|
||||
QBitService service = new(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
);
|
||||
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -88,16 +92,19 @@ public sealed class DownloadServiceFactory
|
||||
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
|
||||
|
||||
|
||||
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
|
||||
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
|
||||
|
||||
// Create the DelugeService instance
|
||||
DelugeService service = new(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
);
|
||||
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
private TransmissionService CreateTransmissionService(DownloadClientConfig downloadClientConfig)
|
||||
{
|
||||
var logger = _serviceProvider.GetRequiredService<ILogger<TransmissionService>>();
|
||||
@@ -109,13 +116,16 @@ public sealed class DownloadServiceFactory
|
||||
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
|
||||
|
||||
|
||||
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
|
||||
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
|
||||
|
||||
// Create the TransmissionService instance
|
||||
TransmissionService service = new(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
);
|
||||
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -131,13 +141,16 @@ public sealed class DownloadServiceFactory
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
|
||||
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
|
||||
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
|
||||
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
|
||||
|
||||
// Create the UTorrentService instance
|
||||
UTorrentService service = new(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory, ruleEvaluator, ruleManager
|
||||
);
|
||||
|
||||
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
@@ -30,7 +31,7 @@ public interface IDownloadService : IDisposable
|
||||
/// Fetches all seeding downloads.
|
||||
/// </summary>
|
||||
/// <returns>A list of downloads that are seeding.</returns>
|
||||
Task<List<object>?> GetSeedingDownloads();
|
||||
Task<List<ITorrentItem>?> GetSeedingDownloads();
|
||||
|
||||
/// <summary>
|
||||
/// Filters downloads that should be cleaned.
|
||||
@@ -38,7 +39,7 @@ public interface IDownloadService : IDisposable
|
||||
/// <param name="downloads">The downloads to filter.</param>
|
||||
/// <param name="categories">The categories by which to filter the downloads.</param>
|
||||
/// <returns>A list of downloads for the provided categories.</returns>
|
||||
List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||
List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories);
|
||||
|
||||
/// <summary>
|
||||
/// Filters downloads that should have their category changed.
|
||||
@@ -46,7 +47,7 @@ public interface IDownloadService : IDisposable
|
||||
/// <param name="downloads">The downloads to filter.</param>
|
||||
/// <param name="categories">The categories by which to filter the downloads.</param>
|
||||
/// <returns>A list of downloads for the provided categories.</returns>
|
||||
List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||
List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the downloads.
|
||||
@@ -55,7 +56,7 @@ public interface IDownloadService : IDisposable
|
||||
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
|
||||
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
||||
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||
Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the category for downloads that have no hardlinks.
|
||||
@@ -63,7 +64,7 @@ public interface IDownloadService : IDisposable
|
||||
/// <param name="downloads">The downloads to change.</param>
|
||||
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
||||
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||
Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a download item.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for QBittorrent TorrentInfo that implements ITorrentItem interface
|
||||
/// </summary>
|
||||
public sealed class QBitItem : ITorrentItem
|
||||
{
|
||||
private readonly TorrentInfo _torrentInfo;
|
||||
private readonly IReadOnlyList<TorrentTracker> _trackers;
|
||||
private readonly bool _isPrivate;
|
||||
|
||||
public QBitItem(TorrentInfo torrentInfo, IReadOnlyList<TorrentTracker> trackers, bool isPrivate)
|
||||
{
|
||||
_torrentInfo = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
|
||||
_trackers = trackers ?? throw new ArgumentNullException(nameof(trackers));
|
||||
_isPrivate = isPrivate;
|
||||
}
|
||||
|
||||
// Basic identification
|
||||
public string Hash => _torrentInfo.Hash ?? string.Empty;
|
||||
public string Name => _torrentInfo.Name ?? string.Empty;
|
||||
|
||||
// Privacy and tracking
|
||||
public bool IsPrivate => _isPrivate;
|
||||
public IReadOnlyList<string> Trackers => _trackers
|
||||
.Where(t => !string.IsNullOrEmpty(t.Url))
|
||||
.Select(t => ExtractHostFromUrl(t.Url!))
|
||||
.Where(host => !string.IsNullOrEmpty(host))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
// Size and progress
|
||||
public long Size => _torrentInfo.Size;
|
||||
public double CompletionPercentage => _torrentInfo.Progress * 100.0;
|
||||
public long DownloadedBytes => _torrentInfo.Downloaded ?? 0;
|
||||
public long TotalUploaded => _torrentInfo.Uploaded ?? 0;
|
||||
|
||||
// Speed and transfer rates
|
||||
public long DownloadSpeed => _torrentInfo.DownloadSpeed;
|
||||
public long UploadSpeed => _torrentInfo.UploadSpeed;
|
||||
public double Ratio => _torrentInfo.Ratio;
|
||||
|
||||
// Time tracking
|
||||
public long Eta => _torrentInfo.EstimatedTime?.TotalSeconds is double eta ? (long)eta : 0;
|
||||
public DateTime? DateAdded => _torrentInfo.AddedOn;
|
||||
public DateTime? DateCompleted => _torrentInfo.CompletionOn;
|
||||
public long SeedingTimeSeconds => _torrentInfo.SeedingTime?.TotalSeconds is double seedTime ? (long)seedTime : 0;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _torrentInfo.Category;
|
||||
public IReadOnlyList<string> Tags => _torrentInfo.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// State checking methods
|
||||
public bool IsDownloading() => _torrentInfo.State is TorrentState.Downloading or TorrentState.ForcedDownload;
|
||||
public bool IsStalled() => _torrentInfo.State is TorrentState.StalledDownload;
|
||||
public bool IsSeeding() => _torrentInfo.State is TorrentState.Uploading or TorrentState.ForcedUpload or TorrentState.StalledUpload;
|
||||
public bool IsCompleted() => CompletionPercentage >= 100.0;
|
||||
public bool IsPaused() => _torrentInfo.State is TorrentState.PausedDownload or TorrentState.PausedUpload;
|
||||
public bool IsQueued() => _torrentInfo.State is TorrentState.QueuedDownload or TorrentState.QueuedUpload;
|
||||
public bool IsChecking() => _torrentInfo.State is TorrentState.CheckingDownload or TorrentState.CheckingUpload or TorrentState.CheckingResumeData;
|
||||
public bool IsAllocating() => _torrentInfo.State is TorrentState.Allocating;
|
||||
public bool IsMetadataDownloading() => _torrentInfo.State is TorrentState.FetchingMetadata or TorrentState.ForcedFetchingMetadata;
|
||||
|
||||
// Filtering methods
|
||||
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (ignoredDownloads.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the host from a tracker URL
|
||||
/// </summary>
|
||||
private static string ExtractHostFromUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -27,10 +28,12 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
) : base(
|
||||
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
_client = new QBittorrentClient(_httpClient, downloadClientConfig.Url);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
@@ -10,31 +11,42 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
public partial class QBitService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
|
||||
{
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Completed });
|
||||
return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
if (torrentList is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<ITorrentItem>();
|
||||
foreach (var torrent in torrentList.Where(x => !string.IsNullOrEmpty(x.Hash)))
|
||||
{
|
||||
var trackers = await GetTrackersAsync(torrent.Hash!);
|
||||
var properties = await _client.GetTorrentPropertiesAsync(torrent.Hash!);
|
||||
bool isPrivate = properties?.AdditionalData.TryGetValue("is_private", out var dictValue) == true &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue;
|
||||
|
||||
result.Add(new QBitItem(torrent, trackers, isPrivate));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories)
|
||||
{
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
|
||||
return downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Where(x =>
|
||||
{
|
||||
@@ -46,12 +58,11 @@ public partial class QBitService
|
||||
|
||||
return true;
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
@@ -59,7 +70,7 @@ public partial class QBitService
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
@@ -72,50 +83,32 @@ public partial class QBitService
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
.FirstOrDefault(x => (download.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate)
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
|
||||
{
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogError("Failed to find torrent properties | {name}", download.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
if (isPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, TimeSpan.FromSeconds(download.SeedingTimeSeconds), category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
{
|
||||
@@ -132,7 +125,7 @@ public partial class QBitService
|
||||
download.Name
|
||||
);
|
||||
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason);
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, TimeSpan.FromSeconds(download.SeedingTimeSeconds), category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,13 +143,13 @@ public partial class QBitService
|
||||
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
@@ -164,7 +157,7 @@ public partial class QBitService
|
||||
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
@@ -177,15 +170,22 @@ public partial class QBitService
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the underlying TorrentInfo to access SavePath and files
|
||||
TorrentInfo? torrentInfo = await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = new[] { download.Hash } })
|
||||
.ContinueWith(t => t.Result?.FirstOrDefault());
|
||||
|
||||
if (torrentInfo is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent info for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(download.Hash);
|
||||
|
||||
if (files is null)
|
||||
@@ -207,7 +207,7 @@ public partial class QBitService
|
||||
break;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.SavePath, file.Name).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
@@ -238,7 +238,7 @@ public partial class QBitService
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
|
||||
|
||||
if (downloadCleanerConfig.UnlinkedUseTag)
|
||||
@@ -248,7 +248,6 @@ public partial class QBitService
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
download.Category = downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ public partial class QBitService
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
@@ -46,6 +39,15 @@ public partial class QBitService
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
var torrentItem = new QBitItem(download, trackers, result.IsPrivate);
|
||||
|
||||
if (torrentItem.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", torrentItem.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
@@ -55,127 +57,80 @@ public partial class QBitService
|
||||
// if all files were blocked by qBittorrent
|
||||
if (download is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", download.Name);
|
||||
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", torrentItem.Name);
|
||||
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", torrentItem.Name);
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
|
||||
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo torrent, bool isPrivate)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) slowResult = await CheckIfSlow(torrentItem);
|
||||
|
||||
if (slowResult.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
return slowResult;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(torrent, isPrivate);
|
||||
return await CheckIfStuck(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download, bool isPrivate)
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
if (!torrentItem.IsDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
|
||||
if (torrentItem.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.State, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = new SmartTimeSpan(download.EstimatedTime ?? TimeSpan.Zero);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.Hash,
|
||||
download.Name,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate)
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
|
||||
if (torrentItem.IsMetadataDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes > 0 && torrent.State is TorrentState.StalledDownload)
|
||||
{
|
||||
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
|
||||
if (queueCleanerConfig.DownloadingMetadataMaxStrikes > 0)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogTrace("skip stalled check | download is private | {name}", torrent.Name);
|
||||
bool shouldRemove = await _striker.StrikeAndCheckLimit(
|
||||
torrentItem.Hash,
|
||||
torrentItem.Name,
|
||||
queueCleanerConfig.DownloadingMetadataMaxStrikes,
|
||||
StrikeType.DownloadingMetadata
|
||||
);
|
||||
|
||||
return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove);
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
|
||||
|
||||
return (
|
||||
await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, queueCleanerConfig.Stalled.MaxStrikes,
|
||||
StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes > 0 && torrent.State is not TorrentState.StalledDownload)
|
||||
if (!torrentItem.IsStalled())
|
||||
{
|
||||
return (
|
||||
await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes,
|
||||
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for Transmission TorrentInfo that implements ITorrentItem interface
|
||||
/// </summary>
|
||||
public sealed class TransmissionItem : ITorrentItem
|
||||
{
|
||||
private readonly TorrentInfo _torrentInfo;
|
||||
|
||||
public TransmissionItem(TorrentInfo torrentInfo)
|
||||
{
|
||||
_torrentInfo = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
|
||||
}
|
||||
|
||||
// Basic identification
|
||||
public string Hash => _torrentInfo.HashString ?? string.Empty;
|
||||
public string Name => _torrentInfo.Name ?? string.Empty;
|
||||
|
||||
// Privacy and tracking
|
||||
public bool IsPrivate => _torrentInfo.IsPrivate ?? false;
|
||||
public IReadOnlyList<string> Trackers => _torrentInfo.Trackers?
|
||||
.Where(t => !string.IsNullOrEmpty(t.Announce))
|
||||
.Select(t => ExtractHostFromUrl(t.Announce!))
|
||||
.Where(host => !string.IsNullOrEmpty(host))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// Size and progress
|
||||
public long Size => _torrentInfo.TotalSize ?? 0;
|
||||
public double CompletionPercentage => _torrentInfo.TotalSize > 0
|
||||
? ((_torrentInfo.DownloadedEver ?? 0) / (double)_torrentInfo.TotalSize) * 100.0
|
||||
: 0.0;
|
||||
public long DownloadedBytes => _torrentInfo.DownloadedEver ?? 0;
|
||||
public long TotalUploaded => _torrentInfo.UploadedEver ?? 0;
|
||||
|
||||
// Speed and transfer rates
|
||||
public long DownloadSpeed => _torrentInfo.RateDownload ?? 0;
|
||||
public long UploadSpeed => _torrentInfo.RateUpload ?? 0;
|
||||
public double Ratio => (_torrentInfo.UploadedEver ?? 0) > 0 && (_torrentInfo.DownloadedEver ?? 0) > 0
|
||||
? (_torrentInfo.UploadedEver ?? 0) / (double)(_torrentInfo.DownloadedEver ?? 1)
|
||||
: 0.0;
|
||||
|
||||
// Time tracking
|
||||
public long Eta => _torrentInfo.Eta ?? 0;
|
||||
public DateTime? DateAdded => _torrentInfo.AddedDate.HasValue
|
||||
? DateTimeOffset.FromUnixTimeSeconds(_torrentInfo.AddedDate.Value).DateTime
|
||||
: null;
|
||||
public DateTime? DateCompleted => _torrentInfo.DoneDate.HasValue && _torrentInfo.DoneDate.Value > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(_torrentInfo.DoneDate.Value).DateTime
|
||||
: null;
|
||||
public long SeedingTimeSeconds => _torrentInfo.SecondsSeeding ?? 0;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _torrentInfo.Labels?.FirstOrDefault();
|
||||
public IReadOnlyList<string> Tags => _torrentInfo.Labels?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// State checking methods
|
||||
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
|
||||
public bool IsDownloading() => _torrentInfo.Status == 4;
|
||||
public bool IsStalled() => _torrentInfo.Status == 4 && (_torrentInfo.RateDownload ?? 0) == 0 && (_torrentInfo.Eta ?? 0) == 0;
|
||||
public bool IsSeeding() => _torrentInfo.Status == 6;
|
||||
public bool IsCompleted() => CompletionPercentage >= 100.0;
|
||||
public bool IsPaused() => _torrentInfo.Status == 0;
|
||||
public bool IsQueued() => _torrentInfo.Status is 1 or 3 or 5;
|
||||
public bool IsChecking() => _torrentInfo.Status == 2;
|
||||
public bool IsAllocating() => false; // Transmission doesn't have a specific allocating state
|
||||
public bool IsMetadataDownloading() => false; // Transmission doesn't have this state
|
||||
|
||||
// Filtering methods
|
||||
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (ignoredDownloads.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the host from a tracker URL
|
||||
/// </summary>
|
||||
private static string ExtractHostFromUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -45,11 +46,13 @@ public partial class TransmissionService : DownloadService, ITransmissionService
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
) : base(
|
||||
logger, cache,
|
||||
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
UriBuilder uriBuilder = new(_downloadClientConfig.Url);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
@@ -9,102 +10,98 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
|
||||
public partial class TransmissionService
|
||||
{
|
||||
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||
(await _client.TorrentGetAsync(Fields))
|
||||
?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
|
||||
{
|
||||
var result = await _client.TorrentGetAsync(Fields);
|
||||
return result?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
.Select(x => (ITorrentItem)new TransmissionItem(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories)
|
||||
{
|
||||
return downloads
|
||||
?
|
||||
.Cast<TorrentInfo>()
|
||||
.Where(x => categories
|
||||
.Any(cat => cat.Name.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase))
|
||||
?.Where(x => categories
|
||||
.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))
|
||||
)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories)
|
||||
{
|
||||
return downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString))
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x =>
|
||||
{
|
||||
if (download.DownloadDir is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir))
|
||||
.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
});
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => x.Name.Equals(download.Category, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate is true)
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SecondsSeeding ?? 0);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.uploadRatio ?? 0, seedingTime, category);
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTimeSeconds);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id);
|
||||
// Get the underlying TorrentInfo to access Id for deletion
|
||||
TorrentInfo? torrentInfo = await GetTorrentAsync(download.Hash);
|
||||
if (torrentInfo is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent info for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, torrentInfo.Id);
|
||||
|
||||
_logger.LogInformation(
|
||||
"download cleaned | {reason} reached | {name}",
|
||||
@@ -114,7 +111,7 @@ public partial class TransmissionService
|
||||
download.Name
|
||||
);
|
||||
|
||||
await _eventPublisher.PublishDownloadCleaned(download.uploadRatio ?? 0, seedingTime, category.Name, result.Reason);
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,64 +120,72 @@ public partial class TransmissionService
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads.Cast<TorrentInfo>())
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString) || string.IsNullOrEmpty(download.Name) || download.DownloadDir == null)
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
// Get the underlying TorrentInfo to access files and DownloadDir
|
||||
TorrentInfo? torrentInfo = await GetTorrentAsync(download.Hash);
|
||||
if (torrentInfo is null || torrentInfo.DownloadDir is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent info for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
if (download.Files is null || download.FileStats is null)
|
||||
|
||||
if (torrentInfo.Files is null || torrentInfo.FileStats is null)
|
||||
{
|
||||
_logger.LogDebug("skip | download has no files | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
for (int i = 0; i < torrentInfo.Files.Length; i++)
|
||||
{
|
||||
TransmissionTorrentFiles file = download.Files[i];
|
||||
TransmissionTorrentFileStats stats = download.FileStats[i];
|
||||
|
||||
TransmissionTorrentFiles file = torrentInfo.Files[i];
|
||||
TransmissionTorrentFileStats stats = torrentInfo.FileStats[i];
|
||||
|
||||
if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, file.Name).Split(['\\', '/']));
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.DownloadDir, file.Name).Split(['\\', '/']));
|
||||
|
||||
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
@@ -200,17 +205,15 @@ public partial class TransmissionService
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
string currentCategory = download.GetCategory();
|
||||
string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/']));
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, download.Id, newLocation);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.DownloadDir = newLocation;
|
||||
string currentCategory = download.Category ?? string.Empty;
|
||||
string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.DownloadDir, downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/']));
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrentInfo.Id, newLocation);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC.Arguments;
|
||||
@@ -23,19 +24,23 @@ public partial class TransmissionService
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool shouldRemove = download.FileStats?.Length > 0;
|
||||
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
var torrentItem = new TransmissionItem(download);
|
||||
|
||||
if (torrentItem.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", torrentItem.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool shouldRemove = download.FileStats?.Length > 0;
|
||||
|
||||
foreach (TransmissionTorrentFileStats stats in download.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
@@ -43,29 +48,30 @@ public partial class TransmissionService
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", torrentItem.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, isPrivate);
|
||||
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles)
|
||||
{
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
@@ -74,101 +80,45 @@ public partial class TransmissionService
|
||||
FilesUnwanted = unwantedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo download, bool isPrivate)
|
||||
|
||||
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(download);
|
||||
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(torrentItem);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(download);
|
||||
return await CheckIfStuck(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download)
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
if (!torrentItem.IsDownloading())
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.Status, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Slow.IgnorePrivate && download.IsPrivate is true)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (download.TotalSize > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
if (torrentItem.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.RateDownload ?? long.MaxValue);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta ?? 0);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.HashString!,
|
||||
download.Name!,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo download)
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
if (!torrentItem.IsStalled())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.Status, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload > 0 || download.Eta > 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Stalled.IgnorePrivate && (download.IsPrivate ?? false))
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(download.HashString!, download.DownloadedEver ?? 0);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(download.HashString!, download.Name!, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
|
||||
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for UTorrent UTorrentItem and UTorrentProperties that implements ITorrentItem interface
|
||||
/// </summary>
|
||||
public sealed class UTorrentItemWrapper : ITorrentItem
|
||||
{
|
||||
private readonly UTorrentItem _torrentItem;
|
||||
private readonly UTorrentProperties _torrentProperties;
|
||||
|
||||
public UTorrentItemWrapper(UTorrentItem torrentItem, UTorrentProperties torrentProperties)
|
||||
{
|
||||
_torrentItem = torrentItem ?? throw new ArgumentNullException(nameof(torrentItem));
|
||||
_torrentProperties = torrentProperties ?? throw new ArgumentNullException(nameof(torrentProperties));
|
||||
}
|
||||
|
||||
// Basic identification
|
||||
public string Hash => _torrentItem.Hash;
|
||||
public string Name => _torrentItem.Name;
|
||||
|
||||
// Privacy and tracking
|
||||
public bool IsPrivate => _torrentProperties.IsPrivate;
|
||||
public IReadOnlyList<string> Trackers => _torrentProperties.TrackerList
|
||||
.Select(ExtractHostFromUrl)
|
||||
.Where(host => !string.IsNullOrEmpty(host))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
// Size and progress
|
||||
public long Size => _torrentItem.Size;
|
||||
public double CompletionPercentage => _torrentItem.Progress / 10.0; // Progress is in permille (1000 = 100%)
|
||||
public long DownloadedBytes => _torrentItem.Downloaded;
|
||||
public long TotalUploaded => _torrentItem.Uploaded;
|
||||
|
||||
// Speed and transfer rates
|
||||
public long DownloadSpeed => _torrentItem.DownloadSpeed;
|
||||
public long UploadSpeed => _torrentItem.UploadSpeed;
|
||||
public double Ratio => _torrentItem.Ratio;
|
||||
|
||||
// Time tracking
|
||||
public long Eta => _torrentItem.ETA;
|
||||
public DateTime? DateAdded => _torrentItem.DateAdded > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(_torrentItem.DateAdded).DateTime
|
||||
: null;
|
||||
public DateTime? DateCompleted => _torrentItem.DateCompletedDateTime;
|
||||
public long SeedingTimeSeconds => (long?)_torrentItem.SeedingTime?.TotalSeconds ?? 0;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _torrentItem.Label;
|
||||
public IReadOnlyList<string> Tags => Array.Empty<string>(); // uTorrent doesn't have tags
|
||||
|
||||
// State checking methods using status bitfield
|
||||
public bool IsDownloading() =>
|
||||
(_torrentItem.Status & UTorrentStatus.Started) != 0 &&
|
||||
(_torrentItem.Status & UTorrentStatus.Checked) != 0 &&
|
||||
(_torrentItem.Status & UTorrentStatus.Error) == 0;
|
||||
|
||||
public bool IsStalled() => IsDownloading() && _torrentItem.DownloadSpeed == 0 && _torrentItem.ETA == 0;
|
||||
|
||||
public bool IsSeeding() => IsDownloading() && _torrentItem.DateCompleted > 0;
|
||||
|
||||
public bool IsCompleted() => _torrentItem.ProgressPercent >= 1.0;
|
||||
|
||||
public bool IsPaused() => (_torrentItem.Status & UTorrentStatus.Paused) != 0;
|
||||
|
||||
public bool IsQueued() => (_torrentItem.Status & UTorrentStatus.Queued) != 0;
|
||||
|
||||
public bool IsChecking() => (_torrentItem.Status & UTorrentStatus.Checking) != 0;
|
||||
|
||||
public bool IsAllocating() => false; // uTorrent doesn't have a specific allocating state
|
||||
|
||||
public bool IsMetadataDownloading() => false; // uTorrent doesn't have this state
|
||||
|
||||
// Filtering methods
|
||||
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (ignoredDownloads.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the host from a tracker URL
|
||||
/// </summary>
|
||||
private static string ExtractHostFromUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -29,11 +30,13 @@ public partial class UTorrentService : DownloadService, IUTorrentService
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
ILoggerFactory loggerFactory
|
||||
ILoggerFactory loggerFactory,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
) : base(
|
||||
logger, cache,
|
||||
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
// Create the new layered client with dependency injection
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
@@ -10,89 +11,80 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
public partial class UTorrentService
|
||||
{
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
|
||||
{
|
||||
var torrents = await _client.GetTorrentsAsync();
|
||||
return torrents
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => x.IsSeeding())
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
var result = new List<ITorrentItem>();
|
||||
|
||||
foreach (var torrent in torrents.Where(x => !string.IsNullOrEmpty(x.Hash) && x.IsSeeding()))
|
||||
{
|
||||
var properties = await _client.GetTorrentPropertiesAsync(torrent.Hash);
|
||||
result.Add(new UTorrentItemWrapper(torrent, properties));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<UTorrentItem>()
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<UTorrentItem>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
|
||||
.FirstOrDefault(x => x.Name.Equals(download.Category, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate && properties.IsPrivate)
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
TimeSpan? seedingTime = download.SeedingTime;
|
||||
if (seedingTime == null)
|
||||
{
|
||||
_logger.LogDebug("skip | could not determine seeding time | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime.Value, category);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTimeSeconds);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
{
|
||||
@@ -108,8 +100,8 @@ public partial class UTorrentService
|
||||
: "MAX_SEED_TIME",
|
||||
download.Name
|
||||
);
|
||||
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime.Value, category.Name, result.Reason);
|
||||
|
||||
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,90 +119,92 @@ public partial class UTorrentService
|
||||
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
|
||||
|
||||
foreach (ITorrentItem download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Category))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
if (download.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
|
||||
// Get the underlying UTorrentItem to access SavePath
|
||||
UTorrentItem? torrentItem = await _client.GetTorrentAsync(download.Hash);
|
||||
if (torrentItem is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(download.Hash);
|
||||
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
|
||||
foreach (var file in files ?? [])
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentItem.SavePath, file.Name).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
long hardlinkCount = _hardLinkFileService
|
||||
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
//TODO change label on download object
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Label, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
download.Label = downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -17,22 +18,24 @@ public partial class UTorrentService
|
||||
DownloadCheckResult result = new();
|
||||
|
||||
UTorrentItem? download = await _client.GetTorrentAsync(hash);
|
||||
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
result.Found = true;
|
||||
|
||||
|
||||
var properties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
result.IsPrivate = properties.IsPrivate;
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
var torrentItem = new UTorrentItemWrapper(download, properties);
|
||||
|
||||
if (torrentItem.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
_logger.LogInformation("skip | download is ignored | {name}", torrentItem.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -46,7 +49,7 @@ public partial class UTorrentService
|
||||
}
|
||||
|
||||
bool shouldRemove = files?.Count > 0;
|
||||
|
||||
|
||||
foreach (var file in files ?? [])
|
||||
{
|
||||
if (file.Priority > 0) // 0 = skip, >0 = wanted
|
||||
@@ -59,116 +62,57 @@ public partial class UTorrentService
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
|
||||
_logger.LogDebug("all files are unwanted | removing download | {name}", torrentItem.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
|
||||
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(UTorrentItem torrent, bool isPrivate)
|
||||
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate);
|
||||
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(torrentItem);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(torrent, isPrivate);
|
||||
return await CheckIfStuck(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(UTorrentItem download, bool isPrivate)
|
||||
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
if (!torrentItem.IsDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (!download.IsDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.StatusMessage, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.ETA);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.Hash,
|
||||
download.Name,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
if (torrentItem.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(UTorrentItem download, bool isPrivate)
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
if (!torrentItem.IsStalled())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (!download.IsDownloading())
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.StatusMessage, download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
if (download.DateCompleted > 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is completed | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed > 0 || download.ETA > 0)
|
||||
{
|
||||
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(download.Hash, download.Downloaded);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(download.Hash, download.Name, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Files;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user