Add dead torrent handling (#627)

This commit is contained in:
Flaminel
2026-06-14 02:14:10 +03:00
committed by GitHub
parent 1cc068c2ab
commit 7aa3224f4d
54 changed files with 4207 additions and 19 deletions

View File

@@ -0,0 +1,103 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class DeadTorrentConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly DeadTorrentConfigController _controller;
public DeadTorrentConfigControllerTests()
{
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
_controller = new DeadTorrentConfigController(logger, _dataContext);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static DeadTorrentConfigRequest ValidRequest(
bool enabled = true,
string targetCategory = "cleanuparr-dead",
bool useTag = false,
ushort maxStrikes = 3,
List<string>? categories = null)
=> new()
{
Enabled = enabled,
TargetCategory = targetCategory,
UseTag = useTag,
MaxStrikes = maxStrikes,
Categories = categories ?? ["movies"],
};
[Fact]
public async Task Update_ValidRequest_PersistsConfig()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
saved.Enabled.ShouldBeTrue();
saved.MaxStrikes.ShouldBe((ushort)5);
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
}
[Fact]
public async Task Update_ThenGet_RoundTrips()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
var result = await _controller.GetDeadTorrentConfig(client.Id);
var ok = result.ShouldBeOfType<OkObjectResult>();
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
config.UseTag.ShouldBeTrue();
config.MaxStrikes.ShouldBe((ushort)4);
}
[Fact]
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
}
[Fact]
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Update_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
result.ShouldBeOfType<NotFoundObjectResult>();
}
}

View File

@@ -50,6 +50,7 @@ public static class ServicesDI
.AddScoped<DownloadCleaner>()
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
.AddScoped<IDeadTorrentService, DeadTorrentService>()
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()

View File

@@ -0,0 +1,14 @@
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record DeadTorrentConfigRequest
{
public bool Enabled { get; init; }
public string TargetCategory { get; init; } = "cleanuparr-dead";
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public List<string> Categories { get; init; } = [];
}

View File

@@ -0,0 +1,25 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record DeadTorrentConfigResponse
{
public bool Enabled { get; init; }
public required string TargetCategory { get; init; }
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public required List<string> Categories { get; init; }
public static DeadTorrentConfigResponse From(DeadTorrentConfig config) => new()
{
Enabled = config.Enabled,
TargetCategory = config.TargetCategory,
UseTag = config.UseTag,
MaxStrikes = config.MaxStrikes,
Categories = config.Categories,
};
}

View File

@@ -16,5 +16,7 @@ public sealed record DownloadCleanerClientResponse
public UnlinkedConfigResponse? UnlinkedConfig { get; init; }
public DeadTorrentConfigResponse? DeadTorrentConfig { get; init; }
public OrphanedFilesConfigResponse? OrphanedFilesConfig { get; init; }
}

View File

@@ -0,0 +1,112 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/dead-torrent-config")]
[Authorize]
public class DeadTorrentConfigController : ControllerBase
{
private readonly ILogger<DeadTorrentConfigController> _logger;
private readonly DataContext _dataContext;
public DeadTorrentConfigController(
ILogger<DeadTorrentConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetDeadTorrentConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var config = await _dataContext.DeadTorrentConfigs
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
return Ok(config is null ? null : DeadTorrentConfigResponse.From(config));
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateDeadTorrentConfig(Guid downloadClientId, [FromBody] DeadTorrentConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent)
{
return BadRequest(new { Message = "Dead torrent handling is not supported for rTorrent (no seeder count available)" });
}
var existing = await _dataContext.DeadTorrentConfigs
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
if (existing is null)
{
existing = new DeadTorrentConfig
{
DownloadClientConfigId = downloadClientId,
};
_dataContext.DeadTorrentConfigs.Add(existing);
}
existing.Enabled = dto.Enabled;
existing.TargetCategory = dto.TargetCategory;
existing.UseTag = dto.UseTag;
existing.MaxStrikes = dto.MaxStrikes;
existing.Categories = dto.Categories;
existing.Validate();
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated dead torrent config for client {ClientId}", downloadClientId);
return Ok(DeadTorrentConfigResponse.From(existing));
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -54,11 +54,15 @@ public sealed class DownloadCleanerConfigController : ControllerBase
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
List<UnlinkedConfig> allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
List<DeadTorrentConfig> allDeadTorrentConfigs = await _dataContext.DeadTorrentConfigs.AsNoTracking().ToListAsync();
List<OrphanedFilesConfig> allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync();
Dictionary<Guid, UnlinkedConfig> unlinkedConfigsByClientId = allUnlinkedConfigs
.GroupBy(u => u.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, DeadTorrentConfig> deadTorrentConfigsByClientId = allDeadTorrentConfigs
.GroupBy(d => d.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, OrphanedFilesConfig> orphanedFilesConfigsByClientId = allOrphanedFilesConfigs
.GroupBy(o => o.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
@@ -70,6 +74,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
List<ISeedingRule> seedingRules = SeedingRuleHelper
.FilterForClient(client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
unlinkedConfigsByClientId.TryGetValue(client.Id, out UnlinkedConfig? unlinkedConfig);
deadTorrentConfigsByClientId.TryGetValue(client.Id, out DeadTorrentConfig? deadTorrentConfig);
orphanedFilesConfigsByClientId.TryGetValue(client.Id, out OrphanedFilesConfig? orphanedFilesConfig);
clients.Add(new DownloadCleanerClientResponse
@@ -80,6 +85,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
DownloadClientTypeName = client.TypeName,
SeedingRules = seedingRules.Select(SeedingRuleResponse.From).ToList(),
UnlinkedConfig = unlinkedConfig is not null ? UnlinkedConfigResponse.From(unlinkedConfig) : null,
DeadTorrentConfig = deadTorrentConfig is not null ? DeadTorrentConfigResponse.From(deadTorrentConfig) : null,
OrphanedFilesConfig = orphanedFilesConfig is not null ? OrphanedFilesConfigResponse.From(orphanedFilesConfig) : null,
});
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
@@ -42,7 +43,7 @@ public sealed class OrphanedFilesConfigController : ControllerBase
.AsNoTracking()
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
return Ok(config);
return Ok(config is null ? null : OrphanedFilesConfigResponse.From(config));
}
finally
{
@@ -113,7 +114,7 @@ public sealed class OrphanedFilesConfigController : ControllerBase
_logger.LogInformation("Updated orphaned files client config for client {ClientId}", downloadClientId);
return Ok(existing ?? candidate);
return Ok(OrphanedFilesConfigResponse.From(existing ?? candidate));
}
finally
{

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
@@ -43,7 +44,7 @@ public class UnlinkedConfigController : ControllerBase
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
return Ok(config);
return Ok(config is null ? null : UnlinkedConfigResponse.From(config));
}
finally
{
@@ -95,7 +96,7 @@ public class UnlinkedConfigController : ControllerBase
_logger.LogInformation("Updated unlinked config for client {ClientId}", downloadClientId);
return Ok(existing);
return Ok(UnlinkedConfigResponse.From(existing));
}
finally
{

View File

@@ -7,6 +7,7 @@ public enum EventType
DownloadingMetadataStrike,
SlowSpeedStrike,
SlowTimeStrike,
DeadTorrentStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged,

View File

@@ -7,4 +7,5 @@ public enum StrikeType
FailedImport,
SlowSpeed,
SlowTime,
DeadTorrent,
}

View File

@@ -856,6 +856,51 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
}
}
public class ChangeTorrentCategoryAsync_Tests : QBitServiceDCTests
{
public ChangeTorrentCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CategoryMode_SetsCategory_AndPublishes()
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
await _fixture.ClientWrapper.Received(1)
.SetTorrentCategoryAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
await _fixture.ClientWrapper.DidNotReceive()
.AddTorrentTagAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
}
[Fact]
public async Task TagMode_AddsTag_AndPublishes()
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true);
await _fixture.ClientWrapper.Received(1)
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
await _fixture.ClientWrapper.DidNotReceive()
.SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", true);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture)

View File

@@ -0,0 +1,203 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
public sealed class DeadTorrentServiceTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IStriker _striker;
private readonly IDownloadService _downloadService;
private readonly DownloadClientConfig _clientConfig;
private readonly DeadTorrentService _sut;
public DeadTorrentServiceTests()
{
_dataContext = TestDataContextFactory.Create(seedData: false);
_striker = Substitute.For<IStriker>();
_downloadService = Substitute.For<IDownloadService>();
_clientConfig = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8080"),
};
_downloadService.ClientConfig.Returns(_clientConfig);
_dataContext.DownloadClients.Add(_clientConfig);
_dataContext.SaveChanges();
_sut = new DeadTorrentService(Substitute.For<ILogger<DeadTorrentService>>(), _dataContext, _striker);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private void AddConfig(bool enabled = true, ushort maxStrikes = 3, bool useTag = false, List<string>? categories = null)
{
_dataContext.DeadTorrentConfigs.Add(new DeadTorrentConfig
{
DownloadClientConfigId = _clientConfig.Id,
Enabled = enabled,
TargetCategory = "cleanuparr-dead",
UseTag = useTag,
MaxStrikes = maxStrikes,
Categories = categories ?? ["movies"],
});
_dataContext.SaveChanges();
}
private static ITorrentItemWrapper CreateTorrent(string hash, string category, int? seederCount, string[]? tags = null)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
torrent.Name.Returns($"Test {hash}");
torrent.Category.Returns(category);
torrent.SeederCount.Returns(seederCount);
torrent.Tags.Returns(tags ?? Array.Empty<string>());
return torrent;
}
[Fact]
public async Task NoConfig_DoesNothing()
{
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
}
[Fact]
public async Task Disabled_DoesNothing()
{
AddConfig(enabled: false);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task ZeroSeeders_BelowThreshold_StrikesButDoesNotMove()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
}
[Fact]
public async Task ZeroSeeders_AtThreshold_MovesToCategory()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(true);
var torrent = CreateTorrent("hash1", "movies", 0);
var downloads = new List<ITorrentItemWrapper> { torrent };
await _sut.ProcessAsync(_downloadService, downloads);
await _downloadService.Received(1).ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", false);
}
[Fact]
public async Task WithSeeders_ResetsStrikesAndDoesNotMove()
{
AddConfig();
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 5) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any<string>(), StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
}
[Fact]
public async Task UnavailableSeederCount_IsDead_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", null) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default);
}
[Fact]
public async Task NegativeSeederCount_IsDead_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", -1) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default);
}
[Fact]
public async Task SkipsTorrentsNotInConfiguredCategories()
{
AddConfig(categories: ["movies"]);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "tv", 0) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task SkipsTorrentsAlreadyInTargetCategory()
{
AddConfig();
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "cleanuparr-dead", 0) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task SkipsTorrentsAlreadyTagged_WhenUseTag()
{
AddConfig(useTag: true);
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0, tags: ["cleanuparr-dead"]) };
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
}
}

View File

@@ -53,6 +53,7 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
_fixture.TimeProvider,
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.DeadTorrentService,
_fixture.OrphanedFilesService);
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)

View File

@@ -53,6 +53,7 @@ public class DownloadCleanerTests : IDisposable
_fixture.TimeProvider,
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.DeadTorrentService,
_fixture.OrphanedFilesService
);
}

View File

@@ -49,6 +49,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
_fixture.TimeProvider,
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.DeadTorrentService,
_fixture.OrphanedFilesService);
}

View File

@@ -60,6 +60,7 @@ public class IntegrationTestFixture : IDisposable
public IHubContext<AppHub> HubContext { get; private set; }
public ISeedingRulesCleanupService SeedingRulesService { get; private set; } = null!;
public IUnlinkedDownloadsService UnlinkedService { get; private set; } = null!;
public IDeadTorrentService DeadTorrentService { get; private set; } = null!;
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } = null!;
// State
@@ -146,6 +147,10 @@ public class IntegrationTestFixture : IDisposable
Substitute.For<ILogger<UnlinkedDownloadsService>>(),
DataContext,
HardLinkFileService);
DeadTorrentService = new DeadTorrentService(
Substitute.For<ILogger<DeadTorrentService>>(),
DataContext,
Striker);
OrphanedFilesService = new OrphanedFilesCleanupService(
Substitute.For<ILogger<OrphanedFilesCleanupService>>(),
DataContext,

View File

@@ -35,6 +35,7 @@ public class JobHandlerFixture : IDisposable
public FakeTimeProvider TimeProvider { get; private set; }
public ISeedingRulesCleanupService SeedingRulesService { get; private set; }
public IUnlinkedDownloadsService UnlinkedService { get; private set; }
public IDeadTorrentService DeadTorrentService { get; private set; }
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; }
public ILogger<SeedingRulesCleanupService> SeedingRulesLogger { get; private set; }
public ILogger<UnlinkedDownloadsService> UnlinkedLogger { get; private set; }
@@ -75,6 +76,7 @@ public class JobHandlerFixture : IDisposable
OrphanedFilesLogger = Substitute.For<ILogger<OrphanedFilesCleanupService>>();
SeedingRulesService = new SeedingRulesCleanupService(SeedingRulesLogger, DataContext);
UnlinkedService = new UnlinkedDownloadsService(UnlinkedLogger, DataContext, HardLinkFileService);
DeadTorrentService = Substitute.For<IDeadTorrentService>();
OrphanedFilesService = new OrphanedFilesCleanupService(
OrphanedFilesLogger,
DataContext,

View File

@@ -157,6 +157,15 @@ public class NotificationPublisherTests
await _configService.Received(1).GetProvidersForEventAsync(expectedEventType);
}
[Fact]
public async Task NotifyStrike_WithDeadTorrent_SendsNoNotification_AndDoesNotThrow()
{
// Act & Assert
await _publisher.NotifyStrike(StrikeType.DeadTorrent, 3);
await _configService.DidNotReceive().GetProvidersForEventAsync(Arg.Any<NotificationEventType>());
}
[Fact]
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
{

View File

@@ -117,6 +117,7 @@ public class EventPublisher : IEventPublisher
StrikeType.FailedImport => EventType.FailedImportStrike,
StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
StrikeType.SlowTime => EventType.SlowTimeStrike,
StrikeType.DeadTorrent => EventType.DeadTorrentStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};

View File

@@ -0,0 +1,101 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <inheritdoc cref="IDeadTorrentService" />
public sealed class DeadTorrentService : IDeadTorrentService
{
private readonly ILogger<DeadTorrentService> _logger;
private readonly DataContext _dataContext;
private readonly IStriker _striker;
public DeadTorrentService(
ILogger<DeadTorrentService> logger,
DataContext dataContext,
IStriker striker)
{
_logger = logger;
_dataContext = dataContext;
_striker = striker;
}
public async Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
{
DeadTorrentConfig? config = await _dataContext.DeadTorrentConfigs
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadService.ClientConfig.Id);
if (config is not { Enabled: true })
{
return;
}
if (config.Categories.Count is 0)
{
_logger.LogWarning("Dead torrent config is enabled but no categories are configured for {name}", downloadService.ClientConfig.Name);
return;
}
List<ITorrentItemWrapper> candidates = clientDownloads
.Where(t => !string.IsNullOrEmpty(t.Hash))
.Where(t => config.Categories.Any(cat => cat.Equals(t.Category, StringComparison.OrdinalIgnoreCase)))
.Where(t => config.UseTag
? !t.Tags.Contains(config.TargetCategory, StringComparer.OrdinalIgnoreCase)
: !config.TargetCategory.Equals(t.Category, StringComparison.OrdinalIgnoreCase))
.ToList();
if (candidates.Count is 0)
{
return;
}
try
{
await downloadService.CreateCategoryAsync(config.TargetCategory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category {category}", config.TargetCategory);
}
foreach (ITorrentItemWrapper torrent in candidates)
{
ContextProvider.SetDownloadClient(downloadService.ClientConfig);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
if (torrent.SeederCount > 0)
{
await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, StrikeType.DeadTorrent);
continue;
}
bool shouldMove = await _striker.StrikeAndCheckLimit(
torrent.Hash,
torrent.Name,
config.MaxStrikes,
StrikeType.DeadTorrent);
if (!shouldMove)
{
continue;
}
await downloadService.ChangeTorrentCategoryAsync(torrent, config.TargetCategory, config.UseTag);
_logger.LogInformation(
"dead torrent moved to {target} | tag: {useTag} | {name}",
config.TargetCategory,
config.UseTag,
torrent.Name);
}
}
}

View File

@@ -0,0 +1,19 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <summary>
/// Moves torrents that have been dead (zero seeders) for a configured number of consecutive runs
/// to a target category/tag, so seeding rules can act on them.
/// </summary>
public interface IDeadTorrentService
{
/// <summary>
/// Strikes torrents reporting zero seeders and moves those that reach the configured limit
/// to the target category/tag for the given client run.
/// </summary>
/// <param name="downloadService">Download-client service for the current client.</param>
/// <param name="clientDownloads">The client's torrent items to evaluate.</param>
Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
}

View File

@@ -156,6 +156,22 @@ public partial class DelugeService
}
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected async Task CreateLabel(string name)
{
await _client.CreateLabel(name);

View File

@@ -138,6 +138,9 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <inheritdoc/>
public abstract Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
/// <inheritdoc/>
public abstract Task CreateCategoryAsync(string name);

View File

@@ -67,6 +67,14 @@ public interface IDownloadService : IDisposable
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Moves a single torrent to the target category, or adds it as a tag/label when <paramref name="useTag"/> is set.
/// </summary>
/// <param name="torrent">The torrent to move.</param>
/// <param name="targetCategory">The target category/tag.</param>
/// <param name="useTag">When true, add a tag/label instead of changing the category (qBittorrent and Transmission).</param>
Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
/// <summary>
/// Deletes a download item.
/// </summary>

View File

@@ -185,6 +185,25 @@ public partial class QBitService
}
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, targetCategory, useTag));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag);
if (!useTag)
{
torrent.Category = targetCategory;
}
}
protected async Task CreateCategory(string name)
{
await _client.AddCategoryAsync(name);

View File

@@ -150,6 +150,22 @@ public partial class RTorrentService
}
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetLabelAsync(hash, newLabel);

View File

@@ -168,6 +168,40 @@ public partial class TransmissionService
}
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
var transmissionTorrent = (TransmissionItemWrapper)torrent;
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
if (useTag)
{
string[] newLabels = torrent.Tags
.Append(targetCategory)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(transmissionTorrent.Info.Id, newLabels));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, isTag: true);
return;
}
string newLocation = transmissionTorrent.Info.GetNewLocationByAppend(targetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
{
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);

View File

@@ -133,6 +133,22 @@ public partial class UTorrentService
}
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabelAsync(hash, newLabel);

View File

@@ -24,6 +24,7 @@ public sealed class DownloadCleaner : GenericHandler
private readonly TimeProvider _timeProvider;
private readonly ISeedingRulesCleanupService _seedingRulesService;
private readonly IUnlinkedDownloadsService _unlinkedService;
private readonly IDeadTorrentService _deadTorrentService;
private readonly IOrphanedFilesCleanupService _orphanedFilesService;
public DownloadCleaner(
@@ -38,6 +39,7 @@ public sealed class DownloadCleaner : GenericHandler
TimeProvider timeProvider,
ISeedingRulesCleanupService seedingRulesService,
IUnlinkedDownloadsService unlinkedService,
IDeadTorrentService deadTorrentService,
IOrphanedFilesCleanupService orphanedFilesService
) : base(
logger, dataContext, cache, messageBus,
@@ -47,6 +49,7 @@ public sealed class DownloadCleaner : GenericHandler
_timeProvider = timeProvider;
_seedingRulesService = seedingRulesService;
_unlinkedService = unlinkedService;
_deadTorrentService = deadTorrentService;
_orphanedFilesService = orphanedFilesService;
}
@@ -156,12 +159,22 @@ public sealed class DownloadCleaner : GenericHandler
using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
await _unlinkedService.ProcessAsync(downloadService, clientDownloads);
try
{
await _deadTorrentService.ProcessAsync(downloadService, clientDownloads);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process dead torrents for download client {clientName}", downloadService.ClientConfig.Name);
}
await _seedingRulesService.CleanAsync(downloadService, clientDownloads);
}
}
else
{
_logger.LogInformation("No seeding downloads found, skipping seeding-rule and unlinked-category processing");
_logger.LogInformation("No seeding downloads found");
}
try

View File

@@ -30,6 +30,13 @@ public class NotificationPublisher : INotificationPublisher
public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount)
{
// Dead torrent strikes originate from the download cleaner, which has no Arr/queue context
// required to build a strike notification. Removal is announced via NotifyDownloadCleaned.
if (strikeType is StrikeType.DeadTorrent)
{
return;
}
try
{
var eventType = MapStrikeTypeToEventType(strikeType);

View File

@@ -0,0 +1,128 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class DeadTorrentConfigTests
{
[Fact]
public void Defaults_EnabledIsFalse()
{
var config = new DeadTorrentConfig();
config.Enabled.ShouldBeFalse();
}
[Fact]
public void Defaults_TargetCategoryIsSet()
{
var config = new DeadTorrentConfig();
config.TargetCategory.ShouldBe("cleanuparr-dead");
}
[Fact]
public void Validate_WhenDisabled_DoesNotThrow()
{
var config = new DeadTorrentConfig
{
Enabled = false,
TargetCategory = "",
Categories = [],
MaxStrikes = 0
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_WithValidConfig_DoesNotThrow()
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "cleanuparr-dead",
Categories = ["movies", "tv"],
MaxStrikes = 3
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_WithEmptyTargetCategory_ThrowsValidationException()
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "",
Categories = ["movies"],
MaxStrikes = 3
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Dead torrent target category is required");
}
[Fact]
public void Validate_WhenEnabled_WithEmptyCategories_ThrowsValidationException()
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "cleanuparr-dead",
Categories = [],
MaxStrikes = 3
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("No dead torrent categories configured");
}
[Fact]
public void Validate_WhenEnabled_WithTargetCategoryInCategories_ThrowsValidationException()
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "cleanuparr-dead",
Categories = ["movies", "cleanuparr-dead"],
MaxStrikes = 3
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("The dead torrent target category should not be present in dead torrent categories");
}
[Fact]
public void Validate_WhenEnabled_WithEmptyCategoryEntry_ThrowsValidationException()
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "cleanuparr-dead",
Categories = ["movies", ""],
MaxStrikes = 3
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Empty dead torrent category filter found");
}
[Theory]
[InlineData(0)]
[InlineData(2)]
public void Validate_WhenEnabled_WithStrikesBelowMinimum_ThrowsValidationException(ushort strikes)
{
var config = new DeadTorrentConfig
{
Enabled = true,
TargetCategory = "cleanuparr-dead",
Categories = ["movies"],
MaxStrikes = strikes
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Dead torrent max strikes must be at least 3");
}
}

View File

@@ -51,7 +51,9 @@ public class DataContext : DbContext
public DbSet<RTorrentSeedingRule> RTorrentSeedingRules { get; set; }
public DbSet<UnlinkedConfig> UnlinkedConfigs { get; set; }
public DbSet<DeadTorrentConfig> DeadTorrentConfigs { get; set; }
public DbSet<ArrConfig> ArrConfigs { get; set; }
public DbSet<ArrInstance> ArrInstances { get; set; }
@@ -371,6 +373,19 @@ public class DataContext : DbContext
entity.HasIndex(u => u.DownloadClientConfigId).IsUnique();
});
// Configure per-client dead torrent config relationship
modelBuilder.Entity<DeadTorrentConfig>(entity =>
{
entity.HasOne(d => d.DownloadClientConfig)
.WithMany()
.HasForeignKey(d => d.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(d => d.DownloadClientConfigId).IsUnique();
entity.Property(d => d.Categories).HasConversion(jsonListConverter);
});
// Configure per-client orphaned files config relationship
modelBuilder.Entity<OrphanedFilesConfig>(entity =>
{

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDeadTorrentConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "dead_torrent_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
target_category = table.Column<string>(type: "TEXT", nullable: false),
use_tag = table.Column<bool>(type: "INTEGER", nullable: false),
max_strikes = table.Column<ushort>(type: "INTEGER", nullable: false),
categories = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_dead_torrent_configs", x => x.id);
table.ForeignKey(
name: "fk_dead_torrent_configs_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_dead_torrent_configs_download_client_config_id",
table: "dead_torrent_configs",
column: "download_client_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "dead_torrent_configs");
}
}
}

View File

@@ -113,6 +113,49 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("max_strikes");
b.Property<string>("TargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("target_category");
b.Property<bool>("UseTag")
.HasColumnType("INTEGER")
.HasColumnName("use_tag");
b.HasKey("Id")
.HasName("pk_dead_torrent_configs");
b.HasIndex("DownloadClientConfigId")
.IsUnique()
.HasDatabaseName("ix_dead_torrent_configs_download_client_config_id");
b.ToTable("dead_torrent_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
{
b.Property<Guid>("Id")
@@ -1887,6 +1930,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")

View File

@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record DeadTorrentConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public bool Enabled { get; set; } = false;
/// <summary>
/// Category/tag a dead torrent is moved to once it reaches <see cref="MaxStrikes"/>.
/// </summary>
public string TargetCategory { get; set; } = "cleanuparr-dead";
/// <summary>
/// When true, add a tag/label instead of changing the category. Supported by qBittorrent and Transmission.
/// </summary>
public bool UseTag { get; set; }
/// <summary>
/// Number of consecutive runs a torrent must report zero seeders before being moved.
/// </summary>
public ushort MaxStrikes { get; set; }
/// <summary>
/// Source categories to scan for dead torrents. At least one must be specified.
/// </summary>
public List<string> Categories { get; set; } = [];
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(TargetCategory))
{
throw new ValidationException("Dead torrent target category is required");
}
if (Categories.Count is 0)
{
throw new ValidationException("No dead torrent categories configured");
}
if (Categories.Contains(TargetCategory, StringComparer.OrdinalIgnoreCase))
{
throw new ValidationException("The dead torrent target category should not be present in dead torrent categories");
}
if (Categories.Any(string.IsNullOrWhiteSpace))
{
throw new ValidationException("Empty dead torrent category filter found");
}
if (MaxStrikes < 3)
{
throw new ValidationException("Dead torrent max strikes must be at least 3");
}
}
}

View File

@@ -5,6 +5,7 @@ import {
DownloadCleanerConfig,
SeedingRule,
UnlinkedConfigModel,
DeadTorrentConfigModel,
OrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
@@ -42,12 +43,17 @@ export class DownloadCleanerApi {
}
// Unlinked config
updateUnlinkedConfig(clientId: string, config: Partial<UnlinkedConfigModel>): Observable<void> {
updateUnlinkedConfig(clientId: string, config: UnlinkedConfigModel): Observable<void> {
return this.http.put<void>(`/api/unlinked-config/${clientId}`, config);
}
// Dead torrent config
updateDeadTorrentConfig(clientId: string, config: DeadTorrentConfigModel): Observable<void> {
return this.http.put<void>(`/api/dead-torrent-config/${clientId}`, config);
}
// Per-client orphaned files config
updateOrphanedFilesConfig(clientId: string, config: Partial<OrphanedFilesConfig>): Observable<OrphanedFilesConfig> {
updateOrphanedFilesConfig(clientId: string, config: OrphanedFilesConfig): Observable<OrphanedFilesConfig> {
return this.http.put<OrphanedFilesConfig>(`/api/orphaned-files-config/${clientId}`, config);
}
}

View File

@@ -90,6 +90,11 @@ export class DocumentationService {
'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target',
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories',
'deadTorrentEnabled': 'enable-dead-torrent-handling',
'deadTorrentTargetCategory': 'dead-torrent-target-category',
'deadTorrentUseTag': 'dead-torrent-use-tag',
'deadTorrentStrikes': 'dead-torrent-strikes',
'deadTorrentCategories': 'dead-torrent-categories',
'orphanedFilesEnabled': 'enabled-per-client',
'orphanedFilesScanDirectories': 'scan-directories',
'orphanedFilesOrphanedDirectory': 'orphaned-directory',

View File

@@ -201,6 +201,48 @@
</div>
</app-accordion>
@if (isDeadTorrentCapableClient()) {
<app-accordion header="Dead Torrents" subtitle="Triage torrents with no seeders" [(expanded)]="deadTorrentExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.deadTorrentConfig?.enabled ?? false"
(checkedChange)="updateDeadTorrentField('enabled', $event)"
hint="When enabled, torrents reporting no seeders (or whose tracker is unreachable) for a number of consecutive runs are moved to a target category/tag"
helpKey="download-cleaner:deadTorrentEnabled" />
@if (client.deadTorrentConfig?.enabled) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [value]="client.deadTorrentConfig?.targetCategory ?? ''"
(valueChange)="updateDeadTorrentField('targetCategory', $event)"
hint="Category/tag dead torrents are moved to. Create a seeding rule for this category to control what happens next."
helpKey="download-cleaner:deadTorrentTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.deadTorrentConfig?.useTag ?? false"
(checkedChange)="updateDeadTorrentField('useTag', $event)"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, uses a tag instead of category'"
helpKey="download-cleaner:deadTorrentUseTag" />
}
<app-number-input label="Strikes" [value]="client.deadTorrentConfig?.maxStrikes ?? null" [min]="3" [step]="1"
(valueChange)="updateDeadTorrentField('maxStrikes', $event ?? 0)"
[error]="deadTorrentStrikesError()"
hint="Consecutive runs with no seeders before moving the torrent (minimum 3). Set this high enough to ride out tracker downtime — e.g. with an hourly cleaner, 168 ≈ 1 week. Strikes reset once seeders are found again."
helpKey="download-cleaner:deadTorrentStrikes" />
<div class="form-divider"></div>
<app-chip-input label="Categories" placeholder="Add category..."
[items]="client.deadTorrentConfig?.categories ?? []"
(itemsChange)="updateDeadTorrentField('categories', $event)"
hint="Categories to scan for dead torrents"
[error]="deadTorrentCategoriesError()"
helpKey="download-cleaner:deadTorrentCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || !!deadTorrentCategoriesError() || !!deadTorrentStrikesError()" (clicked)="saveDeadTorrentConfig()">
{{ deadTorrentSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
</div>
</app-accordion>
}
<app-accordion header="Orphaned Files" subtitle="Move files not associated with any active torrent" [(expanded)]="orphanedFilesExpanded">
<div class="form-stack">
<app-toggle

View File

@@ -15,8 +15,8 @@ import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import {
DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel,
OrphanedFilesConfig,
createDefaultUnlinkedConfig, createDefaultOrphanedFilesConfig,
DeadTorrentConfigModel, OrphanedFilesConfig,
createDefaultUnlinkedConfig, createDefaultDeadTorrentConfig, createDefaultOrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
@@ -73,10 +73,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly saved = signal(false);
readonly unlinkedSaving = signal(false);
readonly unlinkedSaved = signal(false);
readonly deadTorrentSaving = signal(false);
readonly deadTorrentSaved = signal(false);
readonly orphanedFilesSaving = signal(false);
readonly orphanedFilesSaved = signal(false);
readonly rulesReloading = signal(false);
private readonly unlinkedSnapshots = signal<Record<string, string>>({});
private readonly deadTorrentSnapshots = signal<Record<string, string>>({});
// Global settings
readonly enabled = signal(false);
@@ -125,8 +128,18 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|| typeName === DownloadClientTypeName.uTorrent;
});
// Dead torrent detection needs a seeder count; rTorrent does not report one.
readonly isDeadTorrentCapableClient = computed(() => {
const typeName = this.selectedClient()?.downloadClientTypeName;
return typeName === DownloadClientTypeName.qBittorrent
|| typeName === DownloadClientTypeName.Deluge
|| typeName === DownloadClientTypeName.Transmission
|| typeName === DownloadClientTypeName.uTorrent;
});
readonly seedingRulesExpanded = signal(false);
readonly unlinkedExpanded = signal(false);
readonly deadTorrentExpanded = signal(false);
readonly orphanedFilesExpanded = signal(false);
// Seeding rule modal
@@ -212,6 +225,28 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
});
readonly deadTorrentCategoriesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
readonly deadTorrentStrikesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.maxStrikes ?? 0) < 3) {
return 'Strikes must be at least 3';
}
return undefined;
});
readonly orphanedFilesScanDirsError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
@@ -244,6 +279,16 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return saved !== JSON.stringify(client.unlinkedConfig);
});
readonly deadTorrentDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.deadTorrentSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultDeadTorrentConfig());
return saved !== JSON.stringify(client.deadTorrentConfig);
});
readonly orphanedFilesDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
@@ -292,6 +337,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
...c,
seedingRules: c.seedingRules ?? [],
unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(),
deadTorrentConfig: c.deadTorrentConfig ?? createDefaultDeadTorrentConfig(),
orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(),
})));
@@ -300,12 +346,15 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
const unlinkedSnapshots: Record<string, string> = {};
const deadTorrentSnapshots: Record<string, string> = {};
const orphanedFilesSnapshots: Record<string, string> = {};
for (const c of dc.clients ?? []) {
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
deadTorrentSnapshots[c.downloadClientId] = JSON.stringify(c.deadTorrentConfig ?? createDefaultDeadTorrentConfig());
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig());
}
this.unlinkedSnapshots.set(unlinkedSnapshots);
this.deadTorrentSnapshots.set(deadTorrentSnapshots);
this.orphanedFilesSnapshots.set(orphanedFilesSnapshots);
this.loader.stop();
@@ -462,7 +511,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
async onClientChange(newClientId: unknown): Promise<void> {
if (this.unlinkedDirty() || this.orphanedFilesDirty()) {
if (this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty()) {
const confirmed = await this.confirm.confirm({
title: 'Unsaved Changes',
message: 'You have unsaved changes for this client. Discard them?',
@@ -472,10 +521,32 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
if (!confirmed) {
return;
}
const currentId = this.selectedClientId();
if (currentId) {
this.restoreClientEditsFromSnapshot(currentId);
}
}
this.selectedClientId.set(newClientId as string | null);
}
/** Reverts the client's unlinked/dead-torrent/orphaned edits back to their saved snapshots. */
private restoreClientEditsFromSnapshot(clientId: string): void {
this.clientConfigs.update(configs => configs.map(c => {
if (c.downloadClientId !== clientId) {
return c;
}
const unlinked = this.unlinkedSnapshots()[clientId];
const deadTorrent = this.deadTorrentSnapshots()[clientId];
const orphaned = this.orphanedFilesSnapshots()[clientId];
return {
...c,
unlinkedConfig: unlinked ? JSON.parse(unlinked) : c.unlinkedConfig,
deadTorrentConfig: deadTorrent ? JSON.parse(deadTorrent) : c.deadTorrentConfig,
orphanedFilesConfig: orphaned ? JSON.parse(orphaned) : c.orphanedFilesConfig,
};
}));
}
// --- Unlinked config ---
updateUnlinkedField<K extends keyof UnlinkedConfigModel>(field: K, value: UnlinkedConfigModel[K]): void {
@@ -514,6 +585,44 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
}
// --- Dead torrent per-client config ---
updateDeadTorrentField<K extends keyof DeadTorrentConfigModel>(field: K, value: DeadTorrentConfigModel[K]): void {
this.updateSelectedClient(client => ({
...client,
deadTorrentConfig: {
...(client.deadTorrentConfig ?? createDefaultDeadTorrentConfig()),
[field]: value,
},
}));
}
saveDeadTorrentConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.deadTorrentConfig) {
return;
}
this.deadTorrentSaving.set(true);
this.api.updateDeadTorrentConfig(clientId, client.deadTorrentConfig).subscribe({
next: () => {
this.toast.success('Dead torrent config saved');
this.deadTorrentSaving.set(false);
this.deadTorrentSaved.set(true);
setTimeout(() => this.deadTorrentSaved.set(false), 1500);
this.deadTorrentSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.deadTorrentConfig),
}));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save dead torrent config');
this.deadTorrentSaving.set(false);
},
});
}
// --- Orphaned files per-client config ---
updateOrphanedFilesField<K extends keyof OrphanedFilesConfig>(field: K, value: OrphanedFilesConfig[K]): void {
@@ -615,6 +724,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
hasPendingChanges(): boolean {
return this.dirty() || this.unlinkedDirty() || this.orphanedFilesDirty();
return this.dirty() || this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty();
}
}

View File

@@ -24,6 +24,14 @@ export interface UnlinkedConfigModel {
categories: string[];
}
export interface DeadTorrentConfigModel {
enabled: boolean;
targetCategory: string;
useTag: boolean;
maxStrikes: number;
categories: string[];
}
export interface OrphanedFilesConfig {
enabled: boolean;
scanDirectories: string[];
@@ -40,6 +48,7 @@ export interface ClientCleanerConfig {
downloadClientTypeName: string;
seedingRules: SeedingRule[];
unlinkedConfig: UnlinkedConfigModel | null;
deadTorrentConfig: DeadTorrentConfigModel | null;
orphanedFilesConfig: OrphanedFilesConfig | null;
}
@@ -78,6 +87,16 @@ export function createDefaultUnlinkedConfig(): UnlinkedConfigModel {
};
}
export function createDefaultDeadTorrentConfig(): DeadTorrentConfigModel {
return {
enabled: false,
targetCategory: 'cleanuparr-dead',
useTag: false,
maxStrikes: 0,
categories: [],
};
}
export function createDefaultOrphanedFilesConfig(): OrphanedFilesConfig {
return {
enabled: false,