Add queue rules (#332)

This commit is contained in:
Flaminel
2025-10-22 13:46:43 +03:00
committed by GitHub
parent 7aced28262
commit ebb166a7b9
142 changed files with 12552 additions and 4404 deletions

View File

@@ -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>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers

View File

@@ -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();

View File

@@ -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>()

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record UpdateArrConfigRequest
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -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),
};
}

View File

@@ -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);
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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();
}
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; } = [];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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();
}

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
public sealed record NotificationProvidersResponse
{
public List<NotificationProviderResponse> Providers { get; init; } = [];
}

View File

@@ -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()
}
};
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -0,0 +1 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -0,0 +1 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -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;

View File

@@ -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 { }

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]

View File

@@ -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>

View File

@@ -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");
}
}
}

View 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);
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TorrentPrivacyType
{
Public,
Private,
Both
}

View File

@@ -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" />

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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" />

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Application.Features.Arr.Dtos;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
public class ArrConfigDto
{

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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