Compare commits

..

13 Commits

Author SHA1 Message Date
Flaminel
6b94e05092 fixed some comments 2026-02-16 19:07:50 +02:00
Flaminel
d4ac8c8ddf added some tests 2026-02-16 18:14:40 +02:00
Flaminel
9c6560b159 fixed context variables 2026-02-16 17:49:29 +02:00
Flaminel
8fdc49f65a decreased number of concurrent deletes 2026-02-16 17:49:08 +02:00
Flaminel
f906e6ed14 fixed frontend inputs 2026-02-16 16:46:58 +02:00
Flaminel
69b50499b5 fixed files call 2026-02-16 16:46:49 +02:00
Flaminel
cc735bd4e2 fixed with main 2026-02-15 18:03:25 +02:00
Flaminel
76767adb1f added rTorrent support 2026-02-15 17:44:53 +02:00
Flaminel
94acd9afa4 Fix download client inputs (#442) 2026-02-15 04:06:28 +02:00
Flaminel
65d25a72a9 Fix failed import reason display for events (#443) 2026-02-15 03:59:40 +02:00
Flaminel
97eb2fce44 Add strikes page (#438) 2026-02-15 03:57:14 +02:00
Flaminel
701829001c Fix speed and size inputs for queue rules (#440) 2026-02-15 01:19:32 +02:00
Flaminel
8aeeca111c Add strike persistency (#437) 2026-02-14 04:00:05 +02:00
159 changed files with 9605 additions and 980 deletions

View File

@@ -29,12 +29,24 @@ public class EventsController : ControllerBase
[FromQuery] string? eventType = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
[FromQuery] string? search = null,
[FromQuery] string? jobRunId = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 100;
}
if (pageSize > 1000)
{
pageSize = 1000; // Cap at 1000 for performance
}
var query = _context.Events.AsQueryable();
@@ -62,6 +74,12 @@ public class EventsController : ControllerBase
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply job run ID exact-match filter
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
@@ -69,7 +87,10 @@ public class EventsController : ControllerBase
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

View File

@@ -66,7 +66,9 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern)
);
}

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StrikesController : ControllerBase
{
private readonly EventsContext _context;
public StrikesController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets download items with their strikes (grouped), with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
[FromQuery] string? type = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _context.DownloadItems
.Include(d => d.Strikes)
.Where(d => d.Strikes.Any());
// Filter by strike type: only show items that have strikes of this type
if (!string.IsNullOrWhiteSpace(type))
{
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
}
// Apply search filter on title or download hash
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(d =>
EF.Functions.Like(d.Title, pattern) ||
EF.Functions.Like(d.DownloadId, pattern));
}
var totalCount = await query.CountAsync();
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
var items = await query
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
.Skip(skip)
.Take(pageSize)
.ToListAsync();
var dtos = items.Select(d => new DownloadItemStrikesDto
{
DownloadItemId = d.Id,
DownloadId = d.DownloadId,
Title = d.Title,
TotalStrikes = d.Strikes.Count,
StrikesByType = d.Strikes
.GroupBy(s => s.Type)
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
IsMarkedForRemoval = d.IsMarkedForRemoval,
IsRemoved = d.IsRemoved,
IsReturning = d.IsReturning,
Strikes = d.Strikes
.OrderByDescending(s => s.CreatedAt)
.Select(s => new StrikeDetailDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
LastDownloadedBytes = s.LastDownloadedBytes,
JobRunId = s.JobRunId,
}).ToList(),
}).ToList();
return Ok(new PaginatedResult<DownloadItemStrikesDto>
{
Items = dtos,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages,
});
}
/// <summary>
/// Gets the most recent individual strikes with download item info (for dashboard)
/// </summary>
[HttpGet("recent")]
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
[FromQuery] int count = 5)
{
if (count < 1) count = 1;
if (count > 50) count = 50;
var strikes = await _context.Strikes
.Include(s => s.DownloadItem)
.OrderByDescending(s => s.CreatedAt)
.Take(count)
.Select(s => new RecentStrikeDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
DownloadId = s.DownloadItem.DownloadId,
Title = s.DownloadItem.Title,
})
.ToListAsync();
return Ok(strikes);
}
/// <summary>
/// Gets all available strike types
/// </summary>
[HttpGet("types")]
public ActionResult<List<string>> GetStrikeTypes()
{
var types = Enum.GetNames(typeof(StrikeType)).ToList();
return Ok(types);
}
/// <summary>
/// Deletes all strikes for a specific download item
/// </summary>
[HttpDelete("{downloadItemId:guid}")]
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
{
var item = await _context.DownloadItems
.Include(d => d.Strikes)
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
if (item == null)
return NotFound();
_context.Strikes.RemoveRange(item.Strikes);
_context.DownloadItems.Remove(item);
await _context.SaveChangesAsync();
return NoContent();
}
}
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTime LatestStrikeAt { get; set; }
public DateTime FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
}
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View File

@@ -56,8 +56,8 @@ public static class MainDI
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>

View File

@@ -2,7 +2,7 @@ using System;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;

View File

@@ -3,7 +3,7 @@ using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -16,7 +16,7 @@ public sealed record CreateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,7 +24,7 @@ public sealed record CreateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
@@ -33,10 +33,20 @@ public sealed record CreateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ToEntity() => new()
@@ -45,10 +55,10 @@ public sealed record CreateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -12,7 +12,7 @@ public sealed record TestDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -22,10 +22,15 @@ public sealed record TestDownloadClientRequest
public void Validate()
{
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
}
public DownloadClientConfig ToTestConfig() => new()
@@ -35,7 +40,7 @@ public sealed record TestDownloadClientRequest
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,

View File

@@ -16,7 +16,7 @@ public sealed record UpdateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,7 +24,7 @@ public sealed record UpdateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
@@ -33,10 +33,20 @@ public sealed record UpdateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
@@ -45,10 +55,10 @@ public sealed record UpdateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -5,10 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
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;

View File

@@ -30,6 +30,8 @@ public sealed record UpdateGeneralConfigRequest
public List<string> IgnoredDownloads { get; init; } = [];
public ushort StrikeInactivityWindowHours { get; init; } = 24;
public UpdateLoggingConfigRequest Log { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
@@ -44,6 +46,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
@@ -61,6 +64,16 @@ public sealed record UpdateGeneralConfigRequest
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
if (config.StrikeInactivityWindowHours is 0)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
}
if (config.StrikeInactivityWindowHours > 168)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
}
config.Log.Validate();
}

View File

@@ -78,6 +78,21 @@ public sealed class GeneralConfigController : ControllerBase
}
}
[HttpPost("strikes/purge")]
public async Task<IActionResult> PurgeAllStrikes(
[FromServices] EventsContext eventsContext)
{
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
_logger.LogWarning("Purged all strikes: {strikes} strikes, {items} download items removed",
deletedStrikes, deletedItems);
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)

View File

@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -1,7 +1,12 @@
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.SignalR;
using Quartz;
using Serilog.Context;
@@ -14,48 +19,73 @@ public sealed class GenericJob<T> : IJob
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task Execute(IJobExecutionContext context)
{
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
Guid jobRunId = Guid.CreateVersion7();
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobRunStatus? status = null;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
await BroadcastJobStatus(hubContext, jobManagementService, false);
var jobRun = new JobRun { Id = jobRunId, Type = jobType };
eventsContext.JobRuns.Add(jobRun);
await eventsContext.SaveChangesAsync();
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await BroadcastJobStatus(hubContext, jobManagementService, true);
status = JobRunStatus.Completed;
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
status = JobRunStatus.Failed;
}
finally
{
await using var finalScope = _scopeFactory.CreateAsyncScope();
var eventsContext = finalScope.ServiceProvider.GetRequiredService<EventsContext>();
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
if (jobRun is not null)
{
jobRun.CompletedAt = DateTime.UtcNow;
jobRun.Status = status;
await eventsContext.SaveChangesAsync();
}
}
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
{
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
if (isFinished)
{
jobInfo.Status = "Scheduled";
}
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
}
catch (Exception ex)

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.HealthCheck;
public sealed record HealthCheckResult
{
public bool IsHealthy { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan ResponseTime { get; set; }
}

View File

@@ -27,6 +27,8 @@ public interface ITorrentItemWrapper
long SeedingTimeSeconds { get; }
string? Category { get; set; }
string SavePath { get; }
bool IsDownloading();

View File

@@ -0,0 +1,37 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a file within a torrent from rTorrent's XML-RPC f.multicall response
/// </summary>
public sealed record RTorrentFile
{
/// <summary>
/// File index within the torrent (0-based)
/// </summary>
public int Index { get; init; }
/// <summary>
/// File path relative to the torrent base directory
/// </summary>
public required string Path { get; init; }
/// <summary>
/// File size in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Download priority: 0 = skip/don't download, 1 = normal, 2 = high
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Number of completed chunks for this file
/// </summary>
public long CompletedChunks { get; init; }
/// <summary>
/// Total number of chunks for this file
/// </summary>
public long SizeChunks { get; init; }
}

View File

@@ -0,0 +1,72 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a torrent from rTorrent's XML-RPC multicall response
/// </summary>
public sealed record RTorrentTorrent
{
/// <summary>
/// Torrent info hash (40-character hex string, uppercase)
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Torrent name
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Whether the torrent is from a private tracker (0 or 1)
/// </summary>
public int IsPrivate { get; init; }
/// <summary>
/// Total size of the torrent in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Number of bytes completed/downloaded
/// </summary>
public long CompletedBytes { get; init; }
/// <summary>
/// Current download rate in bytes per second
/// </summary>
public long DownRate { get; init; }
/// <summary>
/// Upload/download ratio multiplied by 1000 (e.g., 1500 = 1.5 ratio)
/// </summary>
public long Ratio { get; init; }
/// <summary>
/// Torrent state: 0 = stopped, 1 = started
/// </summary>
public int State { get; init; }
/// <summary>
/// Completion status: 0 = incomplete, 1 = complete
/// </summary>
public int Complete { get; init; }
/// <summary>
/// Unix timestamp when the torrent finished downloading (0 if not finished)
/// </summary>
public long TimestampFinished { get; init; }
/// <summary>
/// Label/category from d.custom1 (commonly used by ruTorrent for labels)
/// </summary>
public string? Label { get; init; }
/// <summary>
/// Base path where the torrent data is stored
/// </summary>
public string? BasePath { get; init; }
/// <summary>
/// List of tracker URLs for this torrent
/// </summary>
public IReadOnlyList<string>? Trackers { get; init; }
}

View File

@@ -6,4 +6,5 @@ public enum DownloadClientTypeName
Deluge,
Transmission,
uTorrent,
rTorrent,
}

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum JobRunStatus
{
Completed,
Failed
}

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Domain.Enums;
public enum JobType
{
QueueCleaner,
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class RTorrentClientException : Exception
{
public RTorrentClientException(string message) : base(message)
{
}
public RTorrentClientException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -65,6 +65,9 @@ public class EventPublisherTests : IDisposable
_loggerMock.Object,
_notificationPublisherMock.Object,
_dryRunInterceptorMock.Object);
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
}
public void Dispose()
@@ -339,7 +342,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
@@ -360,7 +363,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
@@ -378,7 +381,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "def456");
// Act
@@ -404,7 +407,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
var ratio = 1.5;
@@ -475,7 +478,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "cat123");
// Act
@@ -493,7 +496,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "tag123");
// Act
@@ -509,7 +512,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
// Act

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -340,13 +341,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -360,13 +363,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -380,13 +385,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
await sut.DeleteDownload(mockTorrent.Object, false);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceFixture : IDisposable
{
public Mock<ILogger<DelugeService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class DelugeServiceFixture : IDisposable
public DelugeServiceFixture()
{
Logger = new Mock<ILogger<DelugeService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IDelugeClientWrapper>();
@@ -74,14 +70,13 @@ public class DelugeServiceFixture : IDisposable
return new DelugeService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class DelugeServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
@@ -503,13 +504,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains(hash)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -523,13 +526,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitServiceFixture : IDisposable
{
public Mock<ILogger<QBitService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class QBitServiceFixture : IDisposable
public QBitServiceFixture()
{
Logger = new Mock<ILogger<QBitService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
BlocklistProvider =new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
@@ -76,14 +72,13 @@ public class QBitServiceFixture : IDisposable
return new QBitService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -115,7 +110,6 @@ public class QBitServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -470,7 +470,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(false);
// Act
@@ -479,7 +479,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata),
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()),
Times.Once);
}
@@ -533,7 +533,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(true); // Strike limit exceeded
// Act
@@ -600,7 +600,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()),
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()),
Times.Never);
}
}

View File

@@ -0,0 +1,582 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentItemWrapperTests
{
public class PropertyMapping_Tests
{
[Fact]
public void MapsHash()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123DEF456", Name = "Test" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("ABC123DEF456", wrapper.Hash);
}
[Fact]
public void MapsName()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test Torrent Name" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("Test Torrent Name", wrapper.Name);
}
[Fact]
public void MapsIsPrivate_True()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 1 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsPrivate);
}
[Fact]
public void MapsIsPrivate_False()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 0 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsPrivate);
}
[Fact]
public void MapsSize()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", SizeBytes = 1024000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(1024000, wrapper.Size);
}
[Fact]
public void MapsDownloadSpeed()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", DownRate = 500000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(500000, wrapper.DownloadSpeed);
}
[Fact]
public void MapsDownloadedBytes()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", CompletedBytes = 750000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(750000, wrapper.DownloadedBytes);
}
[Fact]
public void MapsCategory()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("movies", wrapper.Category);
}
[Fact]
public void CategoryIsSettable()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
wrapper.Category = "tv";
// Assert
Assert.Equal("tv", wrapper.Category);
}
}
public class Ratio_Tests
{
[Fact]
public void ConvertsRatioFromRTorrentFormat()
{
// rTorrent returns ratio * 1000, so 1500 = 1.5 ratio
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 1500 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(1.5, wrapper.Ratio);
}
[Fact]
public void HandlesZeroRatio()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 0 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Ratio);
}
[Fact]
public void HandlesHighRatio()
{
// Arrange - 10.0 ratio = 10000 in rTorrent
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 10000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(10.0, wrapper.Ratio);
}
}
public class CompletionPercentage_Tests
{
[Fact]
public void CalculatesCorrectPercentage()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(50.0, wrapper.CompletionPercentage);
}
[Fact]
public void ReturnsZero_WhenSizeIsZero()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 0,
CompletedBytes = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0.0, wrapper.CompletionPercentage);
}
[Fact]
public void ReturnsHundred_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 1000
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(100.0, wrapper.CompletionPercentage);
}
}
public class IsDownloading_Tests
{
[Fact]
public void ReturnsTrue_WhenStateIsStartedAndNotComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1, // Started
Complete = 0 // Not complete
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsDownloading());
}
[Fact]
public void ReturnsFalse_WhenStopped()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 0, // Stopped
Complete = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsDownloading());
}
[Fact]
public void ReturnsFalse_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1, // Started
Complete = 1 // Complete (seeding)
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsDownloading());
}
}
public class IsStalled_Tests
{
[Fact]
public void ReturnsTrue_WhenDownloadingWithNoSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsStalled());
}
[Fact]
public void ReturnsFalse_WhenDownloadingWithSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1,
Complete = 0,
DownRate = 100000,
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsStalled());
}
[Fact]
public void ReturnsFalse_WhenNotDownloading()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 0, // Stopped
Complete = 0,
DownRate = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsStalled());
}
}
public class SeedingTime_Tests
{
[Fact]
public void ReturnsZero_WhenNotComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 0,
TimestampFinished = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.SeedingTimeSeconds);
}
[Fact]
public void ReturnsZero_WhenNoFinishTimestamp()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 1,
TimestampFinished = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.SeedingTimeSeconds);
}
[Fact]
public void CalculatesSeedingTime_WhenComplete()
{
// Arrange
var finishedTime = DateTimeOffset.UtcNow.AddHours(-2).ToUnixTimeSeconds();
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 1,
TimestampFinished = finishedTime
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert - should be approximately 2 hours (7200 seconds)
Assert.True(wrapper.SeedingTimeSeconds >= 7190 && wrapper.SeedingTimeSeconds <= 7210);
}
}
public class Eta_Tests
{
[Fact]
public void ReturnsZero_WhenNoDownloadSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500,
DownRate = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Eta);
}
[Fact]
public void CalculatesEta_WhenDownloading()
{
// Arrange - 500 bytes remaining at 100 bytes/sec = 5 seconds ETA
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500,
DownRate = 100
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(5, wrapper.Eta);
}
[Fact]
public void ReturnsZero_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 1000,
DownRate = 100
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Eta);
}
}
public class IsIgnored_Tests
{
[Fact]
public void ReturnsFalse_WhenEmptyIgnoreList()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string>());
// Assert
Assert.False(result);
}
[Fact]
public void ReturnsTrue_WhenHashMatches()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "ABC123" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenHashMatchesCaseInsensitive()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "abc123" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenCategoryMatches()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "movies" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenTrackerDomainMatches()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Label = "movies",
Trackers = new List<string> { "https://tracker.example.com/announce" }
};
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "example.com" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsFalse_WhenNoMatch()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Label = "movies",
Trackers = new List<string> { "https://tracker.example.com/announce" }
};
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "other.com", "tv", "HASH2" });
// Assert
Assert.False(result);
}
}
}

View File

@@ -0,0 +1,689 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
{
private readonly RTorrentServiceFixture _fixture;
public RTorrentServiceDCTests(RTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : RTorrentServiceDCTests
{
public GetSeedingDownloads_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingState()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<RTorrentTorrent>
{
new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", State = 1, Complete = 0, IsPrivate = 0, Label = "" }, // Downloading, not seeding
new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH4", Name = "Torrent 4", State = 0, Complete = 1, IsPrivate = 0, Label = "" } // Stopped, not seeding
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert - only torrents with State=1 AND Complete=1 should be returned
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task ReturnsEmptyList_WhenNoTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(new List<RTorrentTorrent>());
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<RTorrentTorrent>
{
new RTorrentTorrent { Hash = "", Name = "No Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" }
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("HASH1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : RTorrentServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ReturnsNull_WhenDownloadsNull()
{
// Arrange
var sut = _fixture.CreateSut();
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(null, categories);
// Assert
Assert.Null(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : RTorrentServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<string> { "movies", "tv" };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
}
[Fact]
public void SkipsEmptyHashes()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "No Hash", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
};
var categories = new List<string> { "movies" };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("HASH1", result[0].Hash);
}
}
public class DeleteDownload_Tests : RTorrentServiceDCTests
{
public DeleteDownload_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NormalizesHashToUppercase()
{
// Arrange
var sut = _fixture.CreateSut();
var hash = "lowercase";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
mockTorrent.Setup(x => x.SavePath).Returns("/test/path");
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrentAsync("LOWERCASE"))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, deleteSourceFiles: false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrentAsync("LOWERCASE"),
Times.Once);
}
}
public class CreateCategoryAsync_Tests : RTorrentServiceDCTests
{
public CreateCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp_BecauseRTorrentDoesNotSupportCategories()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("test-category");
// Assert - no client calls should be made
_fixture.ClientWrapper.VerifyNoOtherCalls();
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : RTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "", Label = "movies", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task GetFilesThrows_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ThrowsAsync(new Exception("XML-RPC error"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 }, // Skipped
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // Active
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - only called for file2.mkv (the active file)
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync("HASH1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1); // Error / file not found
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.EventPublisher.Verify(
x => x.PublishCategoryChanged("movies", "unlinked", false),
Times.Once);
}
[Fact]
public async Task UpdatesCategoryOnWrapper()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
var downloads = new List<ITorrentItemWrapper> { wrapper };
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
Assert.Equal("unlinked", wrapper.Category);
}
}
}

View File

@@ -0,0 +1,112 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Infrastructure.Features.Files;
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.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceFixture : IDisposable
{
public Mock<ILogger<RTorrentService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IRTorrentClientWrapper> ClientWrapper { get; }
public RTorrentServiceFixture()
{
Logger = new Mock<ILogger<RTorrentService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IRTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public RTorrentService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test rTorrent Client",
TypeName = Domain.Enums.DownloadClientTypeName.rTorrent,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost/RPC2"),
Username = "admin",
Password = "admin",
UrlBase = ""
};
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new RTorrentService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,725 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
{
private readonly RTorrentServiceFixture _fixture;
public RTorrentServiceTests(RTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
// Arrange
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync((RTorrentTorrent?)null);
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentWithEmptyHash_ReturnsEmptyResult()
{
// Arrange
const string hash = "test-hash";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync(new RTorrentTorrent { Hash = "", Name = "Test" });
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIsIgnored_ReturnsEmptyResult_WithFound()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
Label = "ignored-category",
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { "ignored-category" });
// Assert
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 1,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
[Fact]
public async Task NormalizesHashToUppercase()
{
// Arrange
const string hash = "lowercase-hash";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync("LOWERCASE-HASH"))
.ReturnsAsync((RTorrentTorrent?)null);
// Act
await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
_fixture.ClientWrapper.Verify(
x => x.GetTorrentAsync("LOWERCASE-HASH"),
Times.Once);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesSkipped_DeletesFromClient()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 0 }
});
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // At least one wanted
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_FileErrorScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_FileErrorScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetTorrentFilesThrows_ReturnsEmptyResult()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new Exception("XML-RPC error"));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_NotInDownloadingState_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=1 means seeding (not downloading)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 1,
DownRate = 100
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task SlowDownload_ZeroSpeed_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0 means downloading; DownRate=0 means zero speed
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0 means downloading; DownRate > 0 means some speed
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
public class ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task StalledDownload_NotInStalledState_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate > 0 = downloading with speed (not stalled)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 5000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate=0 = stalled (downloading with no speed)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
public class ShouldRemoveFromArrQueueAsync_IntegrationScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_IntegrationScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowCheckPasses_ButStalledCheckFails_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate=0 = stalled (not downloading, so slow check skipped)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
// Slow check is skipped because speed is 0
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never); // Skipped
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Once);
}
[Fact]
public async Task BothChecksPass_DoesNotRemove()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 5000000, // Good speed
SizeBytes = 10000000,
CompletedBytes = 5000000
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
}
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
/// <summary>
/// Test implementation of BlocklistProvider for testing purposes
/// </summary>
public static class TestBlocklistProviderFactory
{
public static BlocklistProvider Create()
{
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
var cache = new MemoryCache(new MemoryCacheOptions());
return new BlocklistProvider(logger, scopeFactory, cache);
}
}

View File

@@ -303,44 +303,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -354,37 +325,20 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "nonexistent-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrentInfo = new TorrentInfo { Id = 456, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert - no exception thrown
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
Times.Never);
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true),
Times.Once);
}
[Fact]
@@ -393,40 +347,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.Setup(x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceFixture : IDisposable
{
public Mock<ILogger<TransmissionService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class TransmissionServiceFixture : IDisposable
public TransmissionServiceFixture()
{
Logger = new Mock<ILogger<TransmissionService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<ITransmissionClientWrapper>();
@@ -74,14 +70,13 @@ public class TransmissionServiceFixture : IDisposable
return new TransmissionService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class TransmissionServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -290,13 +291,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -310,13 +313,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -330,13 +335,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
await sut.DeleteDownload(mockTorrent.Object, false);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceFixture : IDisposable
{
public Mock<ILogger<UTorrentService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class UTorrentServiceFixture : IDisposable
public UTorrentServiceFixture()
{
Logger = new Mock<ILogger<UTorrentService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IUTorrentClientWrapper>();
@@ -74,14 +70,13 @@ public class UTorrentServiceFixture : IDisposable
return new UTorrentService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class UTorrentServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -101,7 +101,8 @@ public class DownloadHunterConsumerTests
InstanceType = InstanceType.Lidarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 999 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -128,7 +129,8 @@ public class DownloadHunterConsumerTests
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}

View File

@@ -282,7 +282,8 @@ public class DownloadHunterTests : IDisposable
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}

View File

@@ -105,7 +105,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 456 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
DeleteReason = DeleteReason.Stalled,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -134,7 +135,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 789 },
Record = CreateQueueRecord(),
RemoveFromClient = false,
DeleteReason = DeleteReason.FailedImport
DeleteReason = DeleteReason.FailedImport,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -162,7 +164,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 111 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.SlowSpeed
DeleteReason = DeleteReason.SlowSpeed,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -191,7 +194,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
DeleteReason = DeleteReason.Stalled,
JobRunId = Guid.NewGuid()
};
}

View File

@@ -10,12 +10,12 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -48,10 +48,7 @@ public class QueueItemRemoverTests : IDisposable
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_eventsContext = new EventsContext(eventsContextOptions);
_eventsContext = TestEventsContextFactory.Create();
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
@@ -59,18 +56,10 @@ public class QueueItemRemoverTests : IDisposable
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
// Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
_eventsContext,
@@ -84,7 +73,8 @@ public class QueueItemRemoverTests : IDisposable
_busMock.Object,
_memoryCache,
_arrClientFactoryMock.Object,
_eventPublisher
_eventPublisher,
_eventsContext
);
// Clear static RecurringHashes before each test
@@ -455,7 +445,8 @@ public class QueueItemRemoverTests : IDisposable
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = Guid.NewGuid()
};
}

View File

@@ -45,6 +45,9 @@ public class JobHandlerFixture : IDisposable
// Setup default behaviors
SetupDefaultBehaviors();
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
}
private void SetupDefaultBehaviors()
@@ -56,6 +59,7 @@ public class JobHandlerFixture : IDisposable
It.IsAny<string>(),
It.IsAny<Domain.Enums.EventSeverity>(),
It.IsAny<object?>(),
It.IsAny<Guid?>(),
It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
}
@@ -123,6 +127,9 @@ public class JobHandlerFixture : IDisposable
TimeProvider = new FakeTimeProvider();
SetupDefaultBehaviors();
// Setup fresh JobRunId for each test
ContextProvider.SetJobRunId(Guid.NewGuid());
}
public void Dispose()

View File

@@ -0,0 +1,31 @@
using Cleanuparr.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory EventsContext instances for testing.
/// SQLite in-memory supports ExecuteUpdateAsync, ExecuteDeleteAsync, and EF.Functions.Like,
/// unlike the EF Core InMemory provider.
/// </summary>
public static class TestEventsContextFactory
{
/// <summary>
/// Creates a new SQLite in-memory EventsContext with schema initialized
/// </summary>
public static EventsContext Create()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
}

View File

@@ -65,7 +65,7 @@ public class NotificationPublisherTests
private void SetupDownloadCleanerContext()
{
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("http://downloadclient.local"));
ContextProvider.Set(ContextProvider.Keys.Hash, "HASH123");
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services;

View File

@@ -7,25 +7,48 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Caching.Memory;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class RuleEvaluatorTests
public class RuleEvaluatorTests : IDisposable
{
private readonly EventsContext _context;
public RuleEvaluatorTests()
{
_context = CreateInMemoryEventsContext();
}
public void Dispose()
{
_context.Dispose();
}
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
[Fact]
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = new StallRule
{
@@ -47,7 +70,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
@@ -64,9 +87,14 @@ public class RuleEvaluatorTests
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
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);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
// Progress below threshold should not reset strikes
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
@@ -84,10 +112,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
@@ -98,7 +126,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -106,10 +134,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
@@ -118,7 +146,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -126,7 +154,7 @@ public class RuleEvaluatorTests
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.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
}
@@ -135,10 +163,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
@@ -147,7 +175,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -155,7 +183,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -163,10 +191,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
@@ -175,14 +203,14 @@ public class RuleEvaluatorTests
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.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);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -190,10 +218,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
@@ -204,7 +232,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -212,10 +240,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
@@ -224,7 +252,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -232,7 +260,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -240,10 +268,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
@@ -252,7 +280,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -260,7 +288,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -268,10 +296,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
@@ -295,10 +323,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
@@ -307,14 +335,14 @@ public class RuleEvaluatorTests
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.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);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -322,10 +350,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Rule",
@@ -339,7 +367,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -348,7 +376,7 @@ public class RuleEvaluatorTests
Assert.True(result.ShouldRemove);
strikerMock.Verify(
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed),
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()),
Times.Once);
strikerMock.Verify(
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
@@ -360,10 +388,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Both Rule",
@@ -377,7 +405,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -385,7 +413,7 @@ public class RuleEvaluatorTests
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.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -393,10 +421,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
var slowRule = CreateSlowRule(
@@ -415,7 +443,7 @@ public class RuleEvaluatorTests
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);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -423,10 +451,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Reset",
@@ -455,10 +483,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed No Reset",
@@ -483,10 +511,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time No Reset",
@@ -511,10 +539,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Strike",
@@ -528,7 +556,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -537,7 +565,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -545,10 +573,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time Strike",
@@ -562,7 +590,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -571,7 +599,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -579,10 +607,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
@@ -591,7 +619,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
@@ -609,12 +637,22 @@ public class RuleEvaluatorTests
[Fact]
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
@@ -623,23 +661,19 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
.Returns(Task.CompletedTask);
long downloadedBytes = 0;
// Act - Any progress should trigger reset when no minimum is set
long downloadedBytes = ByteSize.Parse("1 KB").Bytes;
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);
// Assert
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
}
@@ -712,10 +746,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
@@ -735,10 +769,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
@@ -747,7 +781,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -764,10 +798,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
@@ -776,7 +810,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -793,10 +827,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
@@ -805,7 +839,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -822,10 +856,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
@@ -845,10 +879,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
@@ -857,7 +891,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -874,10 +908,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
@@ -886,7 +920,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -903,10 +937,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
"Speed Delete True",
@@ -921,7 +955,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -939,10 +973,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
@@ -951,7 +985,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();

View File

@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
@@ -19,18 +18,18 @@ namespace Cleanuparr.Infrastructure.Tests.Services;
public class StrikerTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly EventsContext _strikerContext;
private readonly ILogger<Striker> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Striker _striker;
public StrikerTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_strikerContext = CreateInMemoryEventsContext();
_logger = Substitute.For<ILogger<Striker>>();
// Create EventPublisher with mocked dependencies
var eventsContext = CreateMockEventsContext();
var eventsContext = CreateInMemoryEventsContext();
var hubContext = Substitute.For<IHubContext<AppHub>>();
var hubClients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
@@ -53,11 +52,14 @@ public class StrikerTests : IDisposable
notificationPublisher,
dryRunInterceptor);
_striker = new Striker(_logger, _cache, _eventPublisher);
_striker = new Striker(_logger, _strikerContext, _eventPublisher);
// Clear static state before each test
Striker.RecurringHashes.Clear();
// Set up required JobRunId for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
// Set up required context for recurring item events and FailedImport strikes
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
@@ -71,7 +73,7 @@ public class StrikerTests : IDisposable
});
}
private static EventsContext CreateMockEventsContext()
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
@@ -81,7 +83,7 @@ public class StrikerTests : IDisposable
public void Dispose()
{
_cache.Dispose();
_strikerContext.Dispose();
Striker.RecurringHashes.Clear();
}
@@ -336,4 +338,64 @@ public class StrikerTests : IDisposable
Striker.RecurringHashes.Count.ShouldBe(1);
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
[Fact]
public async Task StrikeAndCheckLimit_CreatesNewStrikeRowForEachStrike()
{
// Arrange
const string hash = "strike-rows-test";
const string itemName = "Test Item";
const ushort maxStrikes = 5;
// Act - Strike 3 times
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Should have 3 strike rows
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
var strikeCount = await _strikerContext.Strikes
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled);
strikeCount.ShouldBe(3);
}
[Fact]
public async Task StrikeAndCheckLimit_StoresTitleOnDownloadItem()
{
// Arrange
const string hash = "title-test";
const string itemName = "My Movie Title 2024";
const ushort maxStrikes = 3;
// Act
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
downloadItem.Title.ShouldBe(itemName);
}
[Fact]
public async Task StrikeAndCheckLimit_UpdatesTitleOnDownloadItem_WhenTitleChanges()
{
// Arrange
const string hash = "title-update-test";
const string initialTitle = "Initial Title";
const string updatedTitle = "Updated Title";
const ushort maxStrikes = 5;
// Act - Strike with initial title
await _striker.StrikeAndCheckLimit(hash, initialTitle, maxStrikes, StrikeType.Stalled);
// Strike with updated title
await _striker.StrikeAndCheckLimit(hash, updatedTitle, maxStrikes, StrikeType.Stalled);
// Assert - Title should be updated
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
downloadItem.Title.ShouldBe(updatedTitle);
}
}

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Utilities;
using Shouldly;
using Xunit;

View File

@@ -1,80 +0,0 @@
// using Data.Models.Configuration.ContentBlocker;
// using Data.Models.Configuration.DownloadCleaner;
// using Data.Models.Configuration.QueueCleaner;
// using Infrastructure.Interceptors;
// using Infrastructure.Verticals.ContentBlocker;
// using Infrastructure.Verticals.DownloadClient;
// using Infrastructure.Verticals.Files;
// using Infrastructure.Verticals.ItemStriker;
// using Infrastructure.Verticals.Notifications;
// using Microsoft.Extensions.Caching.Memory;
// using Microsoft.Extensions.Logging;
// using Microsoft.Extensions.Options;
// using NSubstitute;
//
// namespace Infrastructure.Tests.Verticals.DownloadClient;
//
// public class DownloadServiceFixture : IDisposable
// {
// public ILogger<DownloadService> Logger { get; set; }
// public IMemoryCache Cache { get; set; }
// public IStriker Striker { get; set; }
//
// public DownloadServiceFixture()
// {
// Logger = Substitute.For<ILogger<DownloadService>>();
// Cache = Substitute.For<IMemoryCache>();
// Striker = Substitute.For<IStriker>();
// }
//
// public TestDownloadService CreateSut(
// QueueCleanerConfig? queueCleanerConfig = null,
// ContentBlockerConfig? contentBlockerConfig = null
// )
// {
// queueCleanerConfig ??= new QueueCleanerConfig
// {
// Enabled = true,
// RunSequentially = true,
// StalledResetStrikesOnProgress = true,
// StalledMaxStrikes = 3
// };
//
// var queueCleanerOptions = Substitute.For<IOptions<QueueCleanerConfig>>();
// queueCleanerOptions.Value.Returns(queueCleanerConfig);
//
// contentBlockerConfig ??= new ContentBlockerConfig
// {
// Enabled = true
// };
//
// var contentBlockerOptions = Substitute.For<IOptions<ContentBlockerConfig>>();
// contentBlockerOptions.Value.Returns(contentBlockerConfig);
//
// var downloadCleanerOptions = Substitute.For<IOptions<DownloadCleanerConfig>>();
// downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig());
//
// var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
// var notifier = Substitute.For<INotificationPublisher>();
// var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// var hardlinkFileService = Substitute.For<IHardLinkFileService>();
//
// return new TestDownloadService(
// Logger,
// queueCleanerOptions,
// contentBlockerOptions,
// downloadCleanerOptions,
// Cache,
// filenameEvaluator,
// Striker,
// notifier,
// dryRunInterceptor,
// hardlinkFileService
// );
// }
//
// public void Dispose()
// {
// // Cleanup if needed
// }
// }

View File

@@ -1,214 +0,0 @@
// using Data.Models.Configuration.DownloadCleaner;
// using Data.Enums;
// using Data.Models.Cache;
// using Infrastructure.Helpers;
// using Infrastructure.Verticals.Context;
// using Infrastructure.Verticals.DownloadClient;
// using NSubstitute;
// using NSubstitute.ClearExtensions;
// using Shouldly;
//
// namespace Infrastructure.Tests.Verticals.DownloadClient;
//
// public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
// {
// private readonly DownloadServiceFixture _fixture;
//
// public DownloadServiceTests(DownloadServiceFixture fixture)
// {
// _fixture = fixture;
// _fixture.Cache.ClearSubstitute();
// _fixture.Striker.ClearSubstitute();
// }
//
// public class ResetStrikesOnProgressTests : DownloadServiceTests
// {
// public ResetStrikesOnProgressTests(DownloadServiceFixture fixture) : base(fixture)
// {
// }
//
// [Fact]
// public void WhenStalledStrikeDisabled_ShouldNotResetStrikes()
// {
// // Arrange
// TestDownloadService sut = _fixture.CreateSut(queueCleanerConfig: new()
// {
// Enabled = true,
// RunSequentially = true,
// StalledResetStrikesOnProgress = false,
// });
//
// // Act
// sut.ResetStalledStrikesOnProgress("test-hash", 100);
//
// // Assert
// _fixture.Cache.ReceivedCalls().ShouldBeEmpty();
// }
//
// [Fact]
// public void WhenProgressMade_ShouldResetStrikes()
// {
// // Arrange
// const string hash = "test-hash";
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 100 };
//
// _fixture.Cache.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
// .Returns(x =>
// {
// x[1] = stalledCacheItem;
// return true;
// });
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// sut.ResetStalledStrikesOnProgress(hash, 200);
//
// // Assert
// _fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
// }
//
// [Fact]
// public void WhenNoProgress_ShouldNotResetStrikes()
// {
// // Arrange
// const string hash = "test-hash";
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 200 };
//
// _fixture.Cache
// .TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
// .Returns(x =>
// {
// x[1] = stalledCacheItem;
// return true;
// });
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// sut.ResetStalledStrikesOnProgress(hash, 100);
//
// // Assert
// _fixture.Cache.DidNotReceive().Remove(Arg.Any<object>());
// }
// }
//
// public class StrikeAndCheckLimitTests : DownloadServiceTests
// {
// public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture)
// {
// }
// }
//
// public class ShouldCleanDownloadTests : DownloadServiceTests
// {
// public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
// {
// ContextProvider.Set(ContextProvider.Keys.DownloadName, "test-download");
// }
//
// [Fact]
// public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 1.0,
// MinSeedTime = 1,
// MaxSeedTime = -1
// };
// const double ratio = 1.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeTrue(),
// () => result.Reason.ShouldBe(CleanReason.MaxRatioReached)
// );
// }
//
// [Fact]
// public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 1.0,
// MinSeedTime = 3,
// MaxSeedTime = -1
// };
// const double ratio = 1.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeFalse(),
// () => result.Reason.ShouldBe(CleanReason.None)
// );
// }
//
// [Fact]
// public void WhenMaxSeedTimeReached_ShouldReturnTrue()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = -1,
// MinSeedTime = 0,
// MaxSeedTime = 1
// };
// const double ratio = 0.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// SeedingCheckResult result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeTrue(),
// () => result.Reason.ShouldBe(CleanReason.MaxSeedTimeReached)
// );
// }
//
// [Fact]
// public void WhenNeitherConditionMet_ShouldReturnFalse()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 2.0,
// MinSeedTime = 0,
// MaxSeedTime = 3
// };
// const double ratio = 1.0;
// TimeSpan seedingTime = TimeSpan.FromHours(1);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeFalse(),
// () => result.Reason.ShouldBe(CleanReason.None)
// );
// }
// }
// }

View File

@@ -14,7 +14,7 @@ public class EventCleanupService : BackgroundService
private readonly ILogger<EventCleanupService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
private readonly int _retentionDays = 30; // Keep events for 30 days
private readonly int _eventRetentionDays = 30; // Keep events for 30 days
public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
{
@@ -25,7 +25,7 @@ public class EventCleanupService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days",
_cleanupInterval, _retentionDays);
_cleanupInterval, _eventRetentionDays);
while (!stoppingToken.IsCancellationRequested)
{
@@ -59,16 +59,19 @@ public class EventCleanupService : BackgroundService
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
await context.Events
var cutoffDate = DateTime.UtcNow.AddDays(-_eventRetentionDays);
await eventsContext.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
await context.ManualEvents
await eventsContext.ManualEvents
.Where(e => e.Timestamp < cutoffDate)
.Where(e => e.IsResolved)
.ExecuteDeleteAsync();
await CleanupStrikesAsync(eventsContext, dataContext);
}
catch (Exception ex)
{
@@ -76,6 +79,48 @@ public class EventCleanupService : BackgroundService
}
}
private async Task CleanupStrikesAsync(EventsContext eventsContext, DataContext dataContext)
{
var config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var inactivityWindowHours = config.StrikeInactivityWindowHours;
var cutoffDate = DateTime.UtcNow.AddHours(-inactivityWindowHours);
// Sliding window: find items whose most recent strike is older than the inactivity window.
// As long as a download keeps receiving new strikes, all its strikes are preserved.
var inactiveItemIds = await eventsContext.Strikes
.GroupBy(s => s.DownloadItemId)
.Where(g => g.Max(s => s.CreatedAt) < cutoffDate)
.Select(g => g.Key)
.ToListAsync();
if (inactiveItemIds.Count > 0)
{
var deletedStrikesCount = await eventsContext.Strikes
.Where(s => inactiveItemIds.Contains(s.DownloadItemId))
.ExecuteDeleteAsync();
if (deletedStrikesCount > 0)
{
_logger.LogInformation(
"Cleaned up {count} strikes from {items} inactive items (no new strikes for {hours} hours)",
deletedStrikesCount, inactiveItemIds.Count, inactivityWindowHours);
}
}
// Clean up orphaned DownloadItems (those with no strikes)
int deletedDownloadItemsCount = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
if (deletedDownloadItemsCount > 0)
{
_logger.LogTrace("Cleaned up {count} download items with 0 strikes", deletedDownloadItemsCount);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Event cleanup service stopping...");

View File

@@ -43,7 +43,7 @@ public class EventPublisher : IEventPublisher
/// <summary>
/// Generic method for publishing events to database and SignalR clients
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null)
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null)
{
AppEvent eventEntity = new()
{
@@ -54,7 +54,13 @@ public class EventPublisher : IEventPublisher
{
Converters = { new JsonStringEnumConverter() }
}) : null,
TrackingId = trackingId
TrackingId = trackingId,
StrikeId = strikeId,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
@@ -65,7 +71,7 @@ public class EventPublisher : IEventPublisher
_logger.LogTrace("Published event: {eventType}", eventType);
}
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
{
ManualEvent eventEntity = new()
@@ -76,21 +82,26 @@ public class EventPublisher : IEventPublisher
{
Converters = { new JsonStringEnumConverter() }
}) : null,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
// Always send to SignalR clients (not affected by dry run)
await NotifyClientsAsync(eventEntity);
_logger.LogTrace("Published manual event: {message}", message);
}
/// <summary>
/// Publishes a strike event with context data and notifications
/// </summary>
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName)
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null)
{
// Determine the appropriate EventType based on StrikeType
EventType eventType = strikeType switch
@@ -133,7 +144,11 @@ public class EventPublisher : IEventPublisher
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: data);
data: data,
strikeId: strikeId);
// Broadcast strike to SignalR clients for real-time dashboard updates
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName);
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
@@ -145,7 +160,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -153,7 +168,7 @@ public class EventPublisher : IEventPublisher
EventType.QueueItemDeleted,
$"Deleting item from queue with reason: {deleteReason}",
EventSeverity.Important,
data: new { downloadName, hash, removeFromClient, deleteReason });
data: new { itemName, hash, removeFromClient, deleteReason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason);
@@ -165,7 +180,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -173,7 +188,7 @@ public class EventPublisher : IEventPublisher
EventType.DownloadCleaned,
$"Cleaned item from download client with reason: {reason}",
EventSeverity.Important,
data: new { downloadName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
data: new { itemName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
@@ -185,7 +200,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -193,7 +208,7 @@ public class EventPublisher : IEventPublisher
EventType.CategoryChanged,
isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'",
EventSeverity.Information,
data: new { downloadName, hash, oldCategory, newCategory, isTag });
data: new { itemName, hash, oldCategory, newCategory, isTag });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
@@ -204,14 +219,10 @@ public class EventPublisher : IEventPublisher
/// </summary>
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
// Publish the event
await PublishManualAsync(
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
EventSeverity.Important,
data: new { itemName, hash, strikeCount, instanceType, instanceUrl }
data: new { itemName, hash, strikeCount }
);
}
@@ -220,13 +231,10 @@ public class EventPublisher : IEventPublisher
/// </summary>
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
await PublishManualAsync(
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
EventSeverity.Warning,
data: new { itemName, hash, instanceType, instanceUrl }
data: new { itemName, hash }
);
}
@@ -267,4 +275,24 @@ public class EventPublisher : IEventPublisher
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
}
}
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName)
{
try
{
var strike = new
{
Id = strikeId ?? Guid.Empty,
Type = strikeType.ToString(),
CreatedAt = DateTime.UtcNow,
DownloadId = hash,
Title = itemName,
};
await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send strike to SignalR clients");
}
}
}

View File

@@ -4,11 +4,11 @@ namespace Cleanuparr.Infrastructure.Events.Interfaces;
public interface IEventPublisher
{
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null);
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);

View File

@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
namespace Cleanuparr.Infrastructure.Features.Context;
@@ -34,12 +34,23 @@ public static class ContextProvider
return Get<T>(key);
}
public const string JobRunIdKey = "JobRunId";
public static Guid GetJobRunId() =>
Get(JobRunIdKey) as Guid? ?? throw new InvalidOperationException("JobRunId not set in context");
public static Guid? TryGetJobRunId() => Get(JobRunIdKey) as Guid?;
public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id);
public static class Keys
{
public const string Version = "version";
public const string DownloadName = "downloadName";
public const string ItemName = "itemName";
public const string Hash = "hash";
public const string DownloadClientUrl = "downloadClientUrl";
public const string DownloadClientType = "downloadClientType";
public const string DownloadClientName = "downloadClientName";
public const string ArrInstanceUrl = "arrInstanceUrl";
}
}

View File

@@ -44,6 +44,8 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
set => Info.Label = value;
}
public string SavePath => Info.DownloadLocation ?? string.Empty;
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
@@ -20,7 +21,6 @@ public partial class DelugeService : DownloadService, IDelugeService
public DelugeService(
ILogger<DelugeService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -32,7 +32,7 @@ public partial class DelugeService : DownloadService, IDelugeService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -44,7 +44,6 @@ public partial class DelugeService : DownloadService, IDelugeService
// Internal constructor for testing
internal DelugeService(
ILogger<DelugeService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -57,7 +56,7 @@ public partial class DelugeService : DownloadService, IDelugeService
IRuleManager ruleManager,
IDelugeClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -37,9 +37,11 @@ public partial class DelugeService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
string hash = torrent.Hash.ToLowerInvariant();
await _client.DeleteTorrents([hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -72,9 +74,11 @@ public partial class DelugeService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
DelugeContents? contents;
try
@@ -137,14 +141,6 @@ public partial class DelugeService
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.DeleteTorrents([hash], deleteSourceFiles);
}
protected async Task CreateLabel(string name)
{
await _client.CreateLabel(name);

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
@@ -11,26 +11,15 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public class HealthCheckResult
{
public bool IsHealthy { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan ResponseTime { get; set; }
}
public abstract class DownloadService : IDownloadService
{
protected readonly ILogger<DownloadService> _logger;
protected readonly IMemoryCache _cache;
protected readonly IFilenameEvaluator _filenameEvaluator;
protected readonly IStriker _striker;
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected readonly IHardLinkFileService _hardLinkFileService;
protected readonly IEventPublisher _eventPublisher;
@@ -42,7 +31,6 @@ public abstract class DownloadService : IDownloadService
protected DownloadService(
ILogger<DownloadService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -56,15 +44,12 @@ public abstract class DownloadService : IDownloadService
)
{
_logger = logger;
_cache = cache;
_filenameEvaluator = filenameEvaluator;
_striker = striker;
_dryRunInterceptor = dryRunInterceptor;
_hardLinkFileService = hardLinkFileService;
_eventPublisher = eventPublisher;
_blocklistProvider = blocklistProvider;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
_downloadClientConfig = downloadClientConfig;
_httpClient = httpClientProvider.CreateClient(downloadClientConfig);
_ruleEvaluator = ruleEvaluator;
@@ -81,9 +66,6 @@ public abstract class DownloadService : IDownloadService
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task DeleteDownload(string hash, bool deleteSourceFiles);
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
@@ -124,9 +106,11 @@ public abstract class DownloadService : IDownloadService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
@@ -136,7 +120,7 @@ public abstract class DownloadService : IDownloadService
continue;
}
await _dryRunInterceptor.InterceptAsync(() => DeleteDownloadInternal(torrent, category.DeleteSourceFiles));
await _dryRunInterceptor.InterceptAsync(() => DeleteDownload(torrent, category.DeleteSourceFiles));
_logger.LogInformation(
"download cleaned | {reason} reached | delete files: {deleteFiles} | {name}",
@@ -166,7 +150,7 @@ public abstract class DownloadService : IDownloadService
/// </summary>
/// <param name="torrent">The torrent to delete</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles);
public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, SeedingRule category)
{
@@ -220,7 +204,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
TimeSpan minSeedingTime = TimeSpan.FromHours(category.MinSeedTime);
if (category.MinSeedTime > 0 && seedingTime < minSeedingTime)
@@ -246,7 +230,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
TimeSpan maxSeedingTime = TimeSpan.FromHours(category.MaxSeedTime);
if (category.MaxSeedTime > 0 && seedingTime < maxSeedingTime)
@@ -258,4 +242,56 @@ public abstract class DownloadService : IDownloadService
// max seed time is 0 or reached
return true;
}
protected bool TryDeleteFiles(string path, bool failOnNotFound)
{
if (string.IsNullOrEmpty(path))
{
_logger.LogTrace("File path is null or empty");
if (failOnNotFound)
{
return false;
}
return true;
}
if (Directory.Exists(path))
{
try
{
Directory.Delete(path, true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete directory: {path}", path);
return false;
}
}
if (File.Exists(path))
{
try
{
File.Delete(path);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete file: {path}", path);
return false;
}
}
_logger.LogTrace("File path to delete not found: {path}", path);
if (failOnNotFound)
{
return false;
}
return true;
}
}

View File

@@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using RTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent.RTorrentService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
@@ -54,6 +55,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
DownloadClientTypeName.rTorrent => CreateRTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -61,7 +63,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
private QBitService CreateQBitService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<QBitService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
@@ -75,7 +76,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the QBitService instance
QBitService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
@@ -86,7 +87,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
{
var logger = _serviceProvider.GetRequiredService<ILogger<DelugeService>>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
@@ -99,7 +99,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the DelugeService instance
DelugeService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
@@ -109,7 +109,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
private TransmissionService CreateTransmissionService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<TransmissionService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
@@ -123,7 +122,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the TransmissionService instance
TransmissionService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
@@ -154,4 +153,27 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
return service;
}
private RTorrentService CreateRTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<RTorrentService>>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
// Create the RTorrentService instance
RTorrentService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
return service;
}
}

View File

@@ -1,7 +1,5 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
@@ -64,9 +62,9 @@ public interface IDownloadService : IDisposable
/// <summary>
/// Deletes a download item.
/// </summary>
/// <param name="hash">The torrent hash.</param>
/// <param name="item">The torrent item.</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent. Defaults to true.</param>
public Task DeleteDownload(string hash, bool deleteSourceFiles);
public Task DeleteDownload(ITorrentItemWrapper item, bool deleteSourceFiles);
/// <summary>
/// Creates a category.

View File

@@ -47,6 +47,8 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
set => Info.Category = value;
}
public string SavePath => Info.SavePath ?? string.Empty;
public IReadOnlyList<string> Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
public bool IsDownloading() => Info.State is TorrentState.Downloading or TorrentState.ForcedDownload;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -21,7 +22,6 @@ public partial class QBitService : DownloadService, IQBitService
public QBitService(
ILogger<QBitService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -33,7 +33,7 @@ public partial class QBitService : DownloadService, IQBitService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
@@ -44,7 +44,6 @@ public partial class QBitService : DownloadService, IQBitService
// Internal constructor for testing
internal QBitService(
ILogger<QBitService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -57,7 +56,7 @@ public partial class QBitService : DownloadService, IQBitService
IRuleManager ruleManager,
IQBittorrentClientWrapper clientWrapper
) : base(
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{

View File

@@ -61,9 +61,9 @@ public partial class QBitService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
await _client.DeleteAsync([torrent.Hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -104,9 +104,11 @@ public partial class QBitService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
bool hasHardlinks = false;
bool hasErrors = false;
@@ -170,12 +172,6 @@ public partial class QBitService
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
await _client.DeleteAsync([hash], deleteDownloadedData: deleteSourceFiles);
}
protected async Task CreateCategory(string name)
{
await _client.AddCategoryAsync(name);

View File

@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public interface IRTorrentClientWrapper
{
Task<string> GetVersionAsync();
Task<List<RTorrentTorrent>> GetAllTorrentsAsync();
Task<RTorrentTorrent?> GetTorrentAsync(string hash);
Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash);
Task<List<string>> GetTrackersAsync(string hash);
Task DeleteTorrentAsync(string hash);
Task SetFilePriorityAsync(string hash, int fileIndex, int priority);
Task<string?> GetLabelAsync(string hash);
Task SetLabelAsync(string hash, string label);
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public interface IRTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,399 @@
using System.Net.Http.Headers;
using System.Text;
using System.Xml.Linq;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
/// <summary>
/// Low-level XML-RPC client for communicating with rTorrent
/// </summary>
public sealed class RTorrentClient
{
private readonly DownloadClientConfig _config;
private readonly HttpClient _httpClient;
// Fields to request when fetching torrent data via d.multicall2
private static readonly string[] TorrentFields =
[
"d.hash=",
"d.name=",
"d.is_private=",
"d.size_bytes=",
"d.completed_bytes=",
"d.down.rate=",
"d.ratio=",
"d.state=",
"d.complete=",
"d.timestamp.finished=",
"d.custom1=",
"d.base_path="
];
// Fields to request when fetching file data via f.multicall
private static readonly string[] FileFields =
[
"f.path=",
"f.size_bytes=",
"f.priority=",
"f.completed_chunks=",
"f.size_chunks="
];
public RTorrentClient(DownloadClientConfig config, HttpClient httpClient)
{
_config = config;
_httpClient = httpClient;
}
/// <summary>
/// Gets the rTorrent client version for health check
/// </summary>
public async Task<string> GetVersionAsync()
{
var response = await CallAsync("system.client_version");
return ParseStringValue(response);
}
/// <summary>
/// Gets all torrents with their status information
/// </summary>
public async Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
{
var args = new object[] { "", "main" }.Concat(TorrentFields.Cast<object>()).ToArray();
var response = await CallAsync("d.multicall2", args);
return ParseTorrentList(response);
}
/// <summary>
/// Gets a single torrent by hash
/// </summary>
public async Task<RTorrentTorrent?> GetTorrentAsync(string hash)
{
try
{
var fields = TorrentFields.Select(f => f.TrimEnd('=')).ToArray();
var tasks = fields.Select(field => CallAsync(field, hash)).ToArray();
var responses = await Task.WhenAll(tasks);
var values = responses.Select(ParseSingleValue).ToArray();
return CreateTorrentFromValues(values);
}
catch (RTorrentClientException)
{
return null;
}
catch (HttpRequestException)
{
return null;
}
}
/// <summary>
/// Gets all files for a torrent
/// </summary>
public async Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
{
var args = new object[] { hash, "" }.Concat(FileFields.Cast<object>()).ToArray();
var response = await CallAsync("f.multicall", args);
return ParseFileList(response);
}
/// <summary>
/// Gets tracker URLs for a torrent
/// </summary>
public async Task<List<string>> GetTrackersAsync(string hash)
{
var response = await CallAsync("t.multicall", hash, "", "t.url=");
return ParseTrackerList(response);
}
/// <summary>
/// Deletes a torrent from rTorrent
/// </summary>
/// <param name="hash">Torrent hash</param>
public async Task DeleteTorrentAsync(string hash)
{
await CallAsync("d.erase", hash);
}
/// <summary>
/// Sets the priority for a file within a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndex">File index (0-based)</param>
/// <param name="priority">Priority: 0=skip, 1=normal, 2=high</param>
public async Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
{
// rTorrent uses hash:f<index> format for file commands
await CallAsync("f.priority.set", $"{hash}:f{fileIndex}", priority);
}
/// <summary>
/// Gets the label (category) for a torrent
/// </summary>
public async Task<string?> GetLabelAsync(string hash)
{
var response = await CallAsync("d.custom1", hash);
var label = ParseStringValue(response);
return string.IsNullOrEmpty(label) ? null : label;
}
/// <summary>
/// Sets the label (category) for a torrent
/// </summary>
public async Task SetLabelAsync(string hash, string label)
{
await CallAsync("d.custom1.set", hash, label);
}
/// <summary>
/// Sends an XML-RPC call to rTorrent
/// </summary>
private async Task<XElement> CallAsync(string method, params object[] parameters)
{
var requestXml = BuildXmlRpcRequest(method, parameters);
var responseXml = await SendRequestAsync(requestXml);
return ParseXmlRpcResponse(responseXml);
}
private string BuildXmlRpcRequest(string method, object[] parameters)
{
var doc = new XDocument(
new XElement("methodCall",
new XElement("methodName", method),
new XElement("params",
parameters.Select(p => new XElement("param", SerializeValue(p)))
)
)
);
return doc.ToString(SaveOptions.DisableFormatting);
}
private XElement SerializeValue(object? value)
{
return value switch
{
null => new XElement("value", new XElement("string", "")),
string s => new XElement("value", new XElement("string", s)),
int i => new XElement("value", new XElement("i4", i)),
long l => new XElement("value", new XElement("i8", l)),
bool b => new XElement("value", new XElement("boolean", b ? "1" : "0")),
double d => new XElement("value", new XElement("double", d)),
string[] arr => new XElement("value",
new XElement("array",
new XElement("data",
arr.Select(item => new XElement("value", new XElement("string", item)))
)
)
),
object[] arr => new XElement("value",
new XElement("array",
new XElement("data",
arr.Select(item => SerializeValue(item))
)
)
),
_ => new XElement("value", new XElement("string", value.ToString()))
};
}
private async Task<string> SendRequestAsync(string requestXml)
{
var content = new StringContent(requestXml, Encoding.UTF8, "text/xml");
content.Headers.ContentType = new MediaTypeHeaderValue("text/xml");
var response = await _httpClient.PostAsync(_config.Url, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private XElement ParseXmlRpcResponse(string responseXml)
{
var doc = XDocument.Parse(responseXml);
var root = doc.Root;
if (root == null)
{
throw new RTorrentClientException("Invalid XML-RPC response: empty document");
}
// Check for fault response
var fault = root.Element("fault");
if (fault != null)
{
var faultValue = fault.Element("value");
var faultStruct = faultValue?.Element("struct");
var faultString = faultStruct?.Elements("member")
.FirstOrDefault(m => m.Element("name")?.Value == "faultString")
?.Element("value")?.Value ?? "Unknown XML-RPC fault";
throw new RTorrentClientException($"XML-RPC fault: {faultString}");
}
// Get the response value
var paramsElement = root.Element("params");
var param = paramsElement?.Element("param");
var value = param?.Element("value");
if (value == null)
{
throw new RTorrentClientException("Invalid XML-RPC response: missing value");
}
return value;
}
private static string ParseStringValue(XElement value)
{
// Value can be directly text or wrapped in <string>, <i4>, <i8>, etc.
var stringEl = value.Element("string");
if (stringEl != null) return stringEl.Value;
var i4El = value.Element("i4");
if (i4El != null) return i4El.Value;
var i8El = value.Element("i8");
if (i8El != null) return i8El.Value;
// Direct text content
if (!value.HasElements) return value.Value;
return value.Elements().First().Value;
}
private static object? ParseSingleValue(XElement value)
{
var stringEl = value.Element("string");
if (stringEl != null) return stringEl.Value;
var i4El = value.Element("i4");
if (i4El != null) return long.TryParse(i4El.Value, out var i4) ? i4 : 0L;
var i8El = value.Element("i8");
if (i8El != null) return long.TryParse(i8El.Value, out var i8) ? i8 : 0L;
var intEl = value.Element("int");
if (intEl != null) return long.TryParse(intEl.Value, out var intVal) ? intVal : 0L;
var boolEl = value.Element("boolean");
if (boolEl != null) return boolEl.Value == "1";
var doubleEl = value.Element("double");
if (doubleEl != null) return double.TryParse(doubleEl.Value, out var d) ? d : 0.0;
// Direct text content
if (!value.HasElements) return value.Value;
return value.Elements().First().Value;
}
private List<RTorrentTorrent> ParseTorrentList(XElement value)
{
var result = new List<RTorrentTorrent>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
var torrent = CreateTorrentFromValues(values);
if (torrent != null)
{
result.Add(torrent);
}
}
return result;
}
private static RTorrentTorrent? CreateTorrentFromValues(object?[] values)
{
if (values.Length < 12) return null;
return new RTorrentTorrent
{
Hash = values[0]?.ToString() ?? "",
Name = values[1]?.ToString() ?? "",
IsPrivate = Convert.ToInt32(values[2] ?? 0),
SizeBytes = Convert.ToInt64(values[3] ?? 0),
CompletedBytes = Convert.ToInt64(values[4] ?? 0),
DownRate = Convert.ToInt64(values[5] ?? 0),
Ratio = Convert.ToInt64(values[6] ?? 0),
State = Convert.ToInt32(values[7] ?? 0),
Complete = Convert.ToInt32(values[8] ?? 0),
TimestampFinished = Convert.ToInt64(values[9] ?? 0),
Label = values[10]?.ToString(),
BasePath = values[11]?.ToString()
};
}
private List<RTorrentFile> ParseFileList(XElement value)
{
var result = new List<RTorrentFile>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
int index = 0;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
if (values.Length >= 5)
{
result.Add(new RTorrentFile
{
Index = index,
Path = values[0]?.ToString() ?? "",
SizeBytes = Convert.ToInt64(values[1] ?? 0),
Priority = Convert.ToInt32(values[2] ?? 1),
CompletedChunks = Convert.ToInt64(values[3] ?? 0),
SizeChunks = Convert.ToInt64(values[4] ?? 0)
});
index++;
}
}
return result;
}
private List<string> ParseTrackerList(XElement value)
{
var result = new List<string>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var url = innerArray.Elements("value").FirstOrDefault();
if (url != null)
{
var trackerUrl = ParseStringValue(url);
if (!string.IsNullOrEmpty(trackerUrl))
{
result.Add(trackerUrl);
}
}
}
return result;
}
}

View File

@@ -0,0 +1,40 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public sealed class RTorrentClientWrapper : IRTorrentClientWrapper
{
private readonly RTorrentClient _client;
public RTorrentClientWrapper(RTorrentClient client)
{
_client = client;
}
public Task<string> GetVersionAsync()
=> _client.GetVersionAsync();
public Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
=> _client.GetAllTorrentsAsync();
public Task<RTorrentTorrent?> GetTorrentAsync(string hash)
=> _client.GetTorrentAsync(hash);
public Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
=> _client.GetTorrentFilesAsync(hash);
public Task<List<string>> GetTrackersAsync(string hash)
=> _client.GetTrackersAsync(hash);
public Task DeleteTorrentAsync(string hash)
=> _client.DeleteTorrentAsync(hash);
public Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
=> _client.SetFilePriorityAsync(hash, fileIndex, priority);
public Task<string?> GetLabelAsync(string hash)
=> _client.GetLabelAsync(hash);
public Task SetLabelAsync(string hash, string label)
=> _client.SetLabelAsync(hash, label);
}

View File

@@ -0,0 +1,121 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
/// <summary>
/// Wrapper for RTorrentTorrent that implements ITorrentItemWrapper interface
/// </summary>
public sealed class RTorrentItemWrapper : ITorrentItemWrapper
{
public RTorrentTorrent Info { get; }
private readonly IReadOnlyList<string> _trackers;
private string? _category;
public RTorrentItemWrapper(RTorrentTorrent torrent, IReadOnlyList<string>? trackers = null)
{
Info = torrent ?? throw new ArgumentNullException(nameof(torrent));
_trackers = trackers ?? torrent.Trackers ?? [];
_category = torrent.Label;
}
public string Hash => Info.Hash;
public string Name => Info.Name;
public bool IsPrivate => Info.IsPrivate == 1;
public long Size => Info.SizeBytes;
public double CompletionPercentage => Info.SizeBytes > 0
? (Info.CompletedBytes / (double)Info.SizeBytes) * 100.0
: 0.0;
public long DownloadedBytes => Info.CompletedBytes;
public long DownloadSpeed => Info.DownRate;
/// <summary>
/// Ratio from rTorrent (returned as ratio * 1000, so divide by 1000)
/// </summary>
public double Ratio => Info.Ratio / 1000.0;
public long Eta => CalculateEta();
public long SeedingTimeSeconds => CalculateSeedingTime();
public string? Category
{
get => _category;
set => _category = value;
}
public string SavePath => Info.BasePath ?? string.Empty;
/// <summary>
/// Downloading when state is 1 (started) and complete is 0 (not finished)
/// </summary>
public bool IsDownloading() => Info.State == 1 && Info.Complete == 0;
/// <summary>
/// Stalled when downloading but no download speed and no ETA
/// </summary>
public bool IsStalled() => IsDownloading() && Info.DownRate <= 0 && Eta <= 0;
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_trackers.Any(url => UriService.GetDomain(url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
{
return true;
}
}
return false;
}
/// <summary>
/// Calculate ETA based on remaining bytes and download speed
/// </summary>
private long CalculateEta()
{
if (Info.DownRate <= 0) return 0;
long remaining = Info.SizeBytes - Info.CompletedBytes;
if (remaining <= 0) return 0;
return remaining / Info.DownRate;
}
/// <summary>
/// Calculate seeding time based on the timestamp when the torrent finished downloading.
/// rTorrent doesn't natively track seeding time, so we calculate it from completion timestamp.
/// </summary>
private long CalculateSeedingTime()
{
// If not finished yet, no seeding time
if (Info.Complete != 1 || Info.TimestampFinished <= 0)
{
return 0;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var seedingTime = now - Info.TimestampFinished;
return seedingTime > 0 ? seedingTime : 0;
}
}

View File

@@ -0,0 +1,108 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
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.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService : DownloadService, IRTorrentService
{
private readonly IRTorrentClientWrapper _client;
public RTorrentService(
ILogger<RTorrentService> logger,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
var rtorrentClient = new RTorrentClient(downloadClientConfig, _httpClient);
_client = new RTorrentClientWrapper(rtorrentClient);
}
// Internal constructor for testing
internal RTorrentService(
ILogger<RTorrentService> logger,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IRTorrentClientWrapper clientWrapper
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = clientWrapper;
}
/// <summary>
/// rTorrent doesn't have its own authentication - it relies on HTTP Basic Auth
/// handled by the reverse proxy (nginx/apache). No action needed here.
/// </summary>
public override Task LoginAsync()
{
_logger.LogDebug("rTorrent authentication is handled by HTTP Basic Auth via reverse proxy for client {clientId}", _downloadClientConfig.Id);
return Task.CompletedTask;
}
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Try to get the version - this is a simple health check
var version = await _client.GetVersionAsync();
stopwatch.Stop();
_logger.LogDebug("Health check: rTorrent version {version} for client {clientId}", version, _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = true,
ResponseTime = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogWarning(ex, "Health check failed for rTorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,
ErrorMessage = $"Connection failed: {ex.Message}",
ResponseTime = stopwatch.Elapsed
};
}
}
public override void Dispose()
{
}
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
// rTorrent uses uppercase hashes
hash = hash.ToUpperInvariant();
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
result.IsPrivate = download.IsPrivate == 1;
result.Found = true;
// Get trackers for ignore check
var trackers = await _client.GetTrackersAsync(hash);
var torrentWrapper = new RTorrentItemWrapper(download, trackers);
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
{
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
return result;
}
if (files.Count == 0)
{
return result;
}
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
List<(int Index, int Priority)> priorityUpdates = [];
foreach (var file in files)
{
totalFiles++;
string fileName = Path.GetFileName(file.Path);
if (result.ShouldRemove)
{
continue;
}
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority == 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
continue;
}
if (!_filenameEvaluator.IsValid(fileName, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
hasPriorityUpdates = true;
priorityUpdates.Add((file.Index, 0));
_logger.LogInformation("unwanted file found | {file}", file.Path);
continue;
}
_logger.LogTrace("File is valid | {file}", file.Path);
}
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", priorityUpdates.Count, download.Name);
foreach (var (index, priority) in priorityUpdates)
{
await _dryRunInterceptor.InterceptAsync(SetFilePriority, hash, index, priority);
}
return result;
}
protected virtual async Task SetFilePriority(string hash, int index, int priority)
{
await _client.SetFilePriorityAsync(hash, index, priority);
}
}

View File

@@ -0,0 +1,147 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
public override async Task<List<ITorrentItemWrapper>> GetSeedingDownloads()
{
var downloads = await _client.GetAllTorrentsAsync();
return downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
// Seeding: complete=1 (finished) and state=1 (started)
.Where(x => x is { Complete: 1, State: 1 })
.Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
string hash = torrent.Hash.ToUpperInvariant();
await _client.DeleteTorrentAsync(hash);
if (deleteSourceFiles)
{
if (!TryDeleteFiles(torrent.SavePath, true))
{
_logger.LogWarning("Failed to delete files | {name}", torrent.Name);
}
}
}
/// <summary>
/// rTorrent doesn't have native category management. Labels are stored in d.custom1
/// and are created implicitly when set. This is a no-op.
/// </summary>
public override Task CreateCategoryAsync(string name)
{
return Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (RTorrentItemWrapper torrent in downloads.Cast<RTorrentItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(torrent.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
foreach (var file in files)
{
string filePath = string.Join(Path.DirectorySeparatorChar,
Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
continue;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetLabelAsync(hash, newLabel);
}
}

View File

@@ -0,0 +1,108 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
// rTorrent uses uppercase hashes
hash = hash.ToUpperInvariant();
DownloadCheckResult result = new();
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
if (string.IsNullOrEmpty(download?.Hash))
{
_logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
result.IsPrivate = download.IsPrivate == 1;
result.Found = true;
// Get trackers for ignore check
var trackers = await _client.GetTrackersAsync(hash);
RTorrentItemWrapper torrent = new(download, trackers);
if (torrent.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", torrent.Name);
return result;
}
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrent.Name);
return result;
}
// Check if all files are skipped (priority = 0)
bool hasActiveFiles = files.Any(f => f.Priority > 0);
if (files.Count > 0 && !hasActiveFiles)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", torrent.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
result.DeleteFromClient = true;
return result;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent);
return result;
}
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -46,6 +46,8 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
get => Info.GetCategory();
set => Info.AppendCategory(value);
}
public string SavePath => Info.DownloadDir ?? string.Empty;
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
public bool IsDownloading() => Info.Status == 4;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -39,7 +40,6 @@ public partial class TransmissionService : DownloadService, ITransmissionService
public TransmissionService(
ILogger<TransmissionService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -51,7 +51,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -70,7 +70,6 @@ public partial class TransmissionService : DownloadService, ITransmissionService
// Internal constructor for testing
internal TransmissionService(
ILogger<TransmissionService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -83,7 +82,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IRuleManager ruleManager,
ITransmissionClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
@@ -39,10 +38,10 @@ public partial class TransmissionService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
var transmissionTorrent = (TransmissionItemWrapper)torrent;
await RemoveDownloadAsync(transmissionTorrent.Info.Id, deleteSourceFiles);
await _client.TorrentRemoveAsync([transmissionTorrent.Info.Id], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -66,9 +65,11 @@ public partial class TransmissionService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
if (torrent.Info.Files is null || torrent.Info.FileStats is null)
{
@@ -135,21 +136,4 @@ public partial class TransmissionService
{
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
}
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
if (torrent is null)
{
return;
}
await _client.TorrentRemoveAsync([torrent.Id], deleteSourceFiles);
}
protected virtual async Task RemoveDownloadAsync(long downloadId, bool deleteSourceFiles)
{
await _client.TorrentRemoveAsync([downloadId], deleteSourceFiles);
}
}

View File

@@ -45,6 +45,8 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
set => Info.Label = value ?? throw new ArgumentNullException(nameof(value));
}
public string SavePath => Info.SavePath ?? string.Empty;
public bool IsDownloading() =>
(Info.Status & UTorrentStatus.Started) != 0 &&
(Info.Status & UTorrentStatus.Checked) != 0 &&

View File

@@ -1,4 +1,4 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -35,7 +35,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -63,7 +63,6 @@ public partial class UTorrentService : DownloadService, IUTorrentService
// Internal constructor for testing
internal UTorrentService(
ILogger<UTorrentService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -76,7 +75,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IRuleManager ruleManager,
IUTorrentClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -36,9 +36,10 @@ public partial class UTorrentService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
string hash = torrent.Hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -62,9 +63,11 @@ public partial class UTorrentService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);
@@ -118,14 +121,6 @@ public partial class UTorrentService
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{

View File

@@ -15,4 +15,6 @@ public sealed record DownloadHuntRequest<T>
public required T SearchItem { get; init; }
public required QueueRecord Record { get; init; }
public required Guid JobRunId { get; init; }
}

View File

@@ -17,6 +17,8 @@ public sealed record QueueItemRemoveRequest<T>
public required QueueRecord Record { get; init; }
public required bool RemoveFromClient { get; init; }
public required DeleteReason DeleteReason { get; init; }
public required Guid JobRunId { get; init; }
}

View File

@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
@@ -11,9 +11,11 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -26,13 +28,15 @@ public sealed class QueueItemRemover : IQueueItemRemover
private readonly IMemoryCache _cache;
private readonly IArrClientFactory _arrClientFactory;
private readonly IEventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
public QueueItemRemover(
ILogger<QueueItemRemover> logger,
IBus messageBus,
IMemoryCache cache,
IArrClientFactory arrClientFactory,
IEventPublisher eventPublisher
IEventPublisher eventPublisher,
EventsContext eventsContext
)
{
_logger = logger;
@@ -40,6 +44,7 @@ public sealed class QueueItemRemover : IQueueItemRemover
_cache = cache;
_arrClientFactory = arrClientFactory;
_eventPublisher = eventPublisher;
_eventsContext = eventsContext;
}
public async Task RemoveQueueItemAsync<T>(QueueItemRemoveRequest<T> request)
@@ -50,8 +55,18 @@ public sealed class QueueItemRemover : IQueueItemRemover
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Mark the download item as removed in the database
await _eventsContext.DownloadItems
.Where(x => EF.Functions.Like(x.DownloadId, request.Record.DownloadId))
.ExecuteUpdateAsync(setter =>
{
setter.SetProperty(x => x.IsRemoved, true);
setter.SetProperty(x => x.IsMarkedForRemoval, false);
});
// Set context for EventPublisher
ContextProvider.Set(ContextProvider.Keys.DownloadName, request.Record.Title);
ContextProvider.SetJobRunId(request.JobRunId);
ContextProvider.Set(ContextProvider.Keys.ItemName, request.Record.Title);
ContextProvider.Set(ContextProvider.Keys.Hash, request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalUrl ?? request.Instance.Url);
@@ -75,7 +90,8 @@ public sealed class QueueItemRemover : IQueueItemRemover
InstanceType = request.InstanceType,
Instance = request.Instance,
SearchItem = request.SearchItem,
Record = request.Record
Record = request.Record,
JobRunId = request.JobRunId
});
}
catch (HttpRequestException exception)

View File

@@ -11,7 +11,9 @@ public interface IStriker
/// <param name="itemName">The name of the item</param>
/// <param name="maxStrikes">The maximum number of strikes</param>
/// <param name="strikeType">The strike type</param>
/// <param name="lastDownloadedBytes">Optional: bytes downloaded at time of strike (for progress tracking)</param>
/// <returns>True if the limit has been reached, otherwise false</returns>
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType);
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType, long? lastDownloadedBytes = null);
Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType);
}

View File

@@ -1,10 +1,10 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -12,47 +12,62 @@ namespace Cleanuparr.Infrastructure.Features.ItemStriker;
public sealed class Striker : IStriker
{
private readonly ILogger<Striker> _logger;
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly EventsContext _context;
private readonly IEventPublisher _eventPublisher;
public static readonly ConcurrentDictionary<string, string?> RecurringHashes = [];
public Striker(ILogger<Striker> logger, IMemoryCache cache, IEventPublisher eventPublisher)
public Striker(ILogger<Striker> logger, EventsContext context, IEventPublisher eventPublisher)
{
_logger = logger;
_cache = cache;
_context = context;
_eventPublisher = eventPublisher;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
/// <inheritdoc/>
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType, long? lastDownloadedBytes = null)
{
if (maxStrikes is 0)
{
_logger.LogTrace("skip striking for {reason} | max strikes is 0 | {name}", strikeType, itemName);
return false;
}
string key = CacheKeys.Strike(strikeType, hash);
if (!_cache.TryGetValue(key, out int strikeCount))
var downloadItem = await GetOrCreateDownloadItemAsync(hash, itemName);
int existingStrikeCount = await _context.Strikes
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType);
var strike = new Strike
{
strikeCount = 1;
}
else
DownloadItemId = downloadItem.Id,
JobRunId = ContextProvider.GetJobRunId(),
Type = strikeType,
LastDownloadedBytes = lastDownloadedBytes
};
_context.Strikes.Add(strike);
int strikeCount = existingStrikeCount + 1;
// If item was previously removed and gets a new strike, it has returned
if (downloadItem.IsRemoved)
{
++strikeCount;
downloadItem.IsReturning = true;
downloadItem.IsRemoved = false;
downloadItem.IsMarkedForRemoval = false;
}
// Mark for removal when strike limit reached
if (strikeCount >= maxStrikes)
{
downloadItem.IsMarkedForRemoval = true;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
await _eventPublisher.PublishStrike(strikeType, strikeCount, hash, itemName);
_cache.Set(key, strikeCount, _cacheOptions);
await _eventPublisher.PublishStrike(strikeType, strikeCount, hash, itemName, strike.Id);
if (strikeCount < maxStrikes)
{
return false;
@@ -61,7 +76,7 @@ public sealed class Striker : IStriker
if (strikeCount > maxStrikes)
{
_logger.LogWarning("Blocked item keeps coming back | {name}", itemName);
RecurringHashes.TryAdd(hash.ToLowerInvariant(), null);
await _eventPublisher.PublishRecurringItem(hash, itemName, strikeCount);
}
@@ -71,17 +86,51 @@ public sealed class Striker : IStriker
return true;
}
public Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType)
public async Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType)
{
string key = CacheKeys.Strike(strikeType, hash);
var downloadItem = await _context.DownloadItems
.FirstOrDefaultAsync(d => d.DownloadId == hash);
if (_cache.TryGetValue(key, out int strikeCount) && strikeCount > 0)
if (downloadItem is null)
{
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikeCount, itemName);
return;
}
_cache.Remove(key);
var strikesToDelete = await _context.Strikes
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType)
.ToListAsync();
return Task.CompletedTask;
if (strikesToDelete.Count > 0)
{
_context.Strikes.RemoveRange(strikesToDelete);
await _context.SaveChangesAsync();
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikesToDelete.Count, itemName);
}
}
}
private async Task<DownloadItem> GetOrCreateDownloadItemAsync(string hash, string itemName)
{
var downloadItem = await _context.DownloadItems
.FirstOrDefaultAsync(d => d.DownloadId == hash);
if (downloadItem is not null)
{
if (downloadItem.Title != itemName)
{
downloadItem.Title = itemName;
await _context.SaveChangesAsync();
}
return downloadItem;
}
downloadItem = new DownloadItem
{
DownloadId = hash,
Title = itemName
};
_context.DownloadItems.Add(downloadItem);
await _context.SaveChangesAsync();
return downloadItem;
}
}

View File

@@ -72,6 +72,9 @@ public sealed class DownloadCleaner : GenericHandler
foreach (var downloadService in downloadServices)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.LoginAsync();
@@ -142,9 +145,10 @@ public sealed class DownloadCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
var groups = items
@@ -209,6 +213,9 @@ public sealed class DownloadCleaner : GenericHandler
// Process each client with its own filtered downloads
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory);
@@ -222,7 +229,7 @@ public sealed class DownloadCleaner : GenericHandler
downloadService.ClientConfig.Name
);
}
try
{
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory);
@@ -275,6 +282,9 @@ public sealed class DownloadCleaner : GenericHandler
// Process cleaning for each client
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories);

View File

@@ -149,7 +149,8 @@ public abstract class GenericHandler : IHandler
Record = record,
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
};
await _messageBus.Publish(removeRequest);
@@ -163,14 +164,16 @@ public abstract class GenericHandler : IHandler
Record = record,
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
};
await _messageBus.Publish(removeRequest);
}
_logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url);
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important);
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important,
data: new { itemName = record.Title, hash = record.DownloadId });
}
protected SearchItem GetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false)

View File

@@ -96,14 +96,15 @@ public sealed class MalwareBlocker : GenericHandler
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>

View File

@@ -86,9 +86,10 @@ public sealed class QueueCleaner : GenericHandler
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
// push to context
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);

View File

@@ -177,7 +177,7 @@ public class NotificationPublisher : INotificationPublisher
private static NotificationContext BuildDownloadCleanedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
var downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
var downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
var hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
var clientUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.DownloadClientUrl);
@@ -200,7 +200,7 @@ public class NotificationPublisher : INotificationPublisher
private NotificationContext BuildCategoryChangedContext(string oldCategory, string newCategory, bool isTag)
{
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
Uri clientUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.DownloadClientUrl);
NotificationContext context = new()

View File

@@ -4,4 +4,8 @@ public static class LogProperties
{
public const string Category = "Category";
public const string JobName = "JobName";
public const string InstanceName = "InstanceName";
public const string DownloadClientType = "DownloadClientType";
public const string DownloadClientName = "DownloadClientName";
public const string JobRunId = "JobRunId";
}

View File

@@ -38,7 +38,7 @@ public class AppHub : Hub
{
var logs = _logSink.GetRecentLogs();
await Clients.Caller.SendAsync("LogsReceived", logs);
_logger.LogDebug("Sent {count} recent logs to client {connectionId}", logs.Count(), Context.ConnectionId);
// _logger.LogDebug("Sent {count} recent logs to client {connectionId}", logs.Count(), Context.ConnectionId);
}
catch (Exception ex)
{
@@ -59,7 +59,7 @@ public class AppHub : Hub
.ToListAsync();
await Clients.Caller.SendAsync("EventsReceived", events);
_logger.LogDebug("Sent {count} recent events to client {connectionId}", events.Count, Context.ConnectionId);
// _logger.LogDebug("Sent {count} recent events to client {connectionId}", events.Count, Context.ConnectionId);
}
catch (Exception ex)
{
@@ -81,7 +81,7 @@ public class AppHub : Hub
.ToListAsync();
await Clients.Caller.SendAsync("ManualEventsReceived", manualEvents);
_logger.LogDebug("Sent {count} recent manual events to client {connectionId}", manualEvents.Count, Context.ConnectionId);
// _logger.LogDebug("Sent {count} recent manual events to client {connectionId}", manualEvents.Count, Context.ConnectionId);
}
catch (Exception ex)
{
@@ -89,6 +89,35 @@ public class AppHub : Hub
}
}
/// <summary>
/// Client requests recent strikes
/// </summary>
public async Task GetRecentStrikes(int count = 5)
{
try
{
var strikes = await _context.Strikes
.Include(s => s.DownloadItem)
.OrderByDescending(s => s.CreatedAt)
.Take(Math.Min(count, 50))
.Select(s => new
{
s.Id,
Type = s.Type.ToString(),
s.CreatedAt,
DownloadId = s.DownloadItem.DownloadId,
Title = s.DownloadItem.Title,
})
.ToListAsync();
await Clients.Caller.SendAsync("StrikesReceived", strikes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send recent strikes to client");
}
}
/// <summary>
/// Client requests current job statuses
/// </summary>
@@ -110,7 +139,7 @@ public class AppHub : Hub
/// </summary>
public override async Task OnConnectedAsync()
{
_logger.LogTrace("Client connected to AppHub: {ConnectionId}", Context.ConnectionId);
// _logger.LogTrace("Client connected to AppHub: {ConnectionId}", Context.ConnectionId);
var status = _statusSnapshot.Current;
if (status.CurrentVersion is not null || status.LatestVersion is not null)
@@ -126,7 +155,7 @@ public class AppHub : Hub
/// </summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogTrace("Client disconnected from AppHub: {ConnectionId}", Context.ConnectionId);
// _logger.LogTrace("Client disconnected from AppHub: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Globalization;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.SignalR;
using Serilog;
@@ -48,8 +49,12 @@ public class SignalRLogSink : ILogEventSink
Level = logEvent.Level.ToString(),
Message = stringWriter.ToString(),
Exception = logEvent.Exception?.ToString(),
JobName = GetPropertyValue(logEvent, "JobName"),
Category = GetPropertyValue(logEvent, "Category", "SYSTEM"),
JobName = GetPropertyValue(logEvent, LogProperties.JobName),
Category = GetPropertyValue(logEvent, LogProperties.Category, "SYSTEM"),
InstanceName = GetPropertyValue(logEvent, LogProperties.InstanceName),
DownloadClientType = GetPropertyValue(logEvent, LogProperties.DownloadClientType),
DownloadClientName = GetPropertyValue(logEvent, LogProperties.DownloadClientName),
JobRunId = GetPropertyValue(logEvent, LogProperties.JobRunId),
};
// Add to buffer for new clients

View File

@@ -1,12 +0,0 @@
namespace Cleanuparr.Infrastructure.Models;
/// <summary>
/// Represents the supported job types in the application
/// </summary>
public enum JobType
{
QueueCleaner,
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Quartz;

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;

View File

@@ -1,14 +1,14 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Services;
@@ -17,29 +17,25 @@ public class RuleEvaluator : IRuleEvaluator
{
private readonly IRuleManager _ruleManager;
private readonly IStriker _striker;
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly EventsContext _context;
private readonly ILogger<RuleEvaluator> _logger;
public RuleEvaluator(
IRuleManager ruleManager,
IStriker striker,
IMemoryCache cache,
EventsContext context,
ILogger<RuleEvaluator> logger)
{
_ruleManager = ruleManager;
_striker = striker;
_cache = cache;
_context = context;
_logger = logger;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent)
{
_logger.LogTrace("Evaluating stall rules | {name}", torrent.Name);
// Get matching stall rules in priority order
var rule = _ruleManager.GetMatchingStallRule(torrent);
if (rule is null)
@@ -57,12 +53,13 @@ public class RuleEvaluator : IRuleEvaluator
rule.MinimumProgressByteSize?.Bytes
);
// Apply strike and check if torrent should be removed
long currentDownloaded = Math.Max(0, torrent.DownloadedBytes);
bool shouldRemove = await _striker.StrikeAndCheckLimit(
torrent.Hash,
torrent.Name,
(ushort)rule.MaxStrikes,
StrikeType.Stalled
StrikeType.Stalled,
currentDownloaded
);
if (shouldRemove)
@@ -77,7 +74,6 @@ public class RuleEvaluator : IRuleEvaluator
{
_logger.LogTrace("Evaluating slow rules | {name}", torrent.Name);
// Get matching slow rules in priority order
SlowRule? rule = _ruleManager.GetMatchingSlowRule(torrent);
if (rule is null)
@@ -89,7 +85,6 @@ public class RuleEvaluator : IRuleEvaluator
_logger.LogTrace("Applying slow rule {rule} | {name}", rule.Name, torrent.Name);
ContextProvider.Set<QueueRule>(rule);
// Check if slow speed
if (!string.IsNullOrWhiteSpace(rule.MinSpeed))
{
ByteSize minSpeed = rule.MinSpeedByteSize;
@@ -114,7 +109,6 @@ public class RuleEvaluator : IRuleEvaluator
}
}
// Check if slow time
if (rule.MaxTimeHours > 0)
{
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(rule.MaxTimeHours);
@@ -153,7 +147,8 @@ public class RuleEvaluator : IRuleEvaluator
return;
}
if (!HasStalledDownloadProgress(torrent, StrikeType.Stalled, out long previous, out long current))
var (hasProgress, previous, current) = await GetDownloadProgressAsync(torrent);
if (!hasProgress)
{
_logger.LogTrace("No progress detected | strikes are not reset | {name}", torrent.Name);
return;
@@ -171,10 +166,10 @@ public class RuleEvaluator : IRuleEvaluator
minimumProgressBytes,
torrent.Name
);
return;
}
_logger.LogTrace(
"Progress detected | strikes are reset | progress: {progress}b | minimum: {minimum}b | {name}",
progressBytes,
@@ -204,34 +199,36 @@ public class RuleEvaluator : IRuleEvaluator
{
return;
}
await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, strikeType);
}
private bool HasStalledDownloadProgress(ITorrentItemWrapper torrent, StrikeType strikeType, out long previousDownloaded, out long currentDownloaded)
private async Task<(bool HasProgress, long PreviousDownloaded, long CurrentDownloaded)> GetDownloadProgressAsync(ITorrentItemWrapper torrent)
{
previousDownloaded = 0;
currentDownloaded = Math.Max(0, torrent.DownloadedBytes);
long currentDownloaded = Math.Max(0, torrent.DownloadedBytes);
string cacheKey = CacheKeys.StrikeItem(torrent.Hash, strikeType);
var downloadItem = await _context.DownloadItems
.FirstOrDefaultAsync(d => d.DownloadId == torrent.Hash);
if (!_cache.TryGetValue(cacheKey, out StalledCacheItem? cachedItem) || cachedItem is null)
if (downloadItem is null)
{
cachedItem = new StalledCacheItem { Downloaded = currentDownloaded };
_cache.Set(cacheKey, cachedItem, _cacheOptions);
return false;
return (false, 0, currentDownloaded);
}
previousDownloaded = cachedItem.Downloaded;
// Get the most recent strike for this download item (Stalled type) to check progress
var mostRecentStrike = await _context.Strikes
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
bool progressed = currentDownloaded > cachedItem.Downloaded;
if (progressed || currentDownloaded != cachedItem.Downloaded)
if (mostRecentStrike is null)
{
cachedItem.Downloaded = currentDownloaded;
_cache.Set(cacheKey, cachedItem, _cacheOptions);
return (false, 0, currentDownloaded);
}
return progressed;
long previousDownloaded = mostRecentStrike.LastDownloadedBytes ?? 0;
bool progressed = currentDownloaded > previousDownloaded;
return (progressed, previousDownloaded, currentDownloaded);
}
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Shared.Helpers;

View File

@@ -12,6 +12,7 @@ using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Serilog.Events;
@@ -202,16 +203,32 @@ public class DataContext : DbContext
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// Use OriginalString for Uri properties to preserve the exact input (including embedded credentials)
foreach (var property in entityType.GetProperties().Where(p => p.ClrType == typeof(Uri)))
{
property.SetValueConverter(
new ValueConverter<Uri, string>(
v => v.OriginalString,
v => new Uri(v, UriKind.RelativeOrAbsolute)));
property.SetValueComparer(new ValueComparer<Uri>(
(u1, u2) => u1 != null && u2 != null
? u1.OriginalString == u2.OriginalString
: u1 == null && u2 == null,
u => u == null ? 0 : u.OriginalString.GetHashCode(),
u => u == null ? null! : new Uri(u.OriginalString, UriKind.RelativeOrAbsolute)));
}
var enumProperties = entityType.ClrType.GetProperties()
.Where(p => p.PropertyType.IsEnum ||
(p.PropertyType.IsGenericType &&
p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
.Where(p => p.PropertyType.IsEnum ||
(p.PropertyType.IsGenericType &&
p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
p.PropertyType.GetGenericArguments()[0].IsEnum));
foreach (var property in enumProperties)
{
var enumType = property.PropertyType.IsEnum
? property.PropertyType
var enumType = property.PropertyType.IsEnum
? property.PropertyType
: property.PropertyType.GetGenericArguments()[0];
var converterType = typeof(LowercaseEnumConverter<>).MakeGenericType(enumType);

View File

@@ -1,5 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Converters;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -12,8 +14,14 @@ namespace Cleanuparr.Persistence;
public class EventsContext : DbContext
{
public DbSet<AppEvent> Events { get; set; }
public DbSet<ManualEvent> ManualEvents { get; set; }
public DbSet<Strike> Strikes { get; set; }
public DbSet<DownloadItem> DownloadItems { get; set; }
public DbSet<JobRun> JobRuns { get; set; }
public EventsContext()
{
@@ -50,6 +58,29 @@ public class EventsContext : DbContext
{
entity.Property(e => e.Timestamp)
.HasConversion(new UtcDateTimeConverter());
entity.HasOne(e => e.Strike)
.WithMany()
.HasForeignKey(e => e.StrikeId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Strike>(entity =>
{
entity.Property(e => e.CreatedAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(e => e.Type)
.HasConversion(new LowercaseEnumConverter<StrikeType>());
});
modelBuilder.Entity<JobRun>(entity =>
{
entity.Property(e => e.StartedAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(e => e.CompletedAt)
.HasConversion(new UtcDateTimeConverter());
});
foreach (var entityType in modelBuilder.Model.GetEntityTypes())

Some files were not shown because too many files have changed in this diff Show More