diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs new file mode 100644 index 00000000..42f921fa --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs @@ -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>(); + _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? 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(); + 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 { "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(); + var config = ok.Value.ShouldBeOfType(); + config.UseTag.ShouldBeTrue(); + config.MaxStrikes.ShouldBe((ushort)4); + } + + [Fact] + public async Task Update_StrikesBelowMinimum_ThrowsValidationException() + { + var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext); + + await Should.ThrowAsync(() => _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(); + } + + [Fact] + public async Task Update_NonExistentClient_ReturnsNotFound() + { + var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest()); + + result.ShouldBeOfType(); + } +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 763aa53d..4b07d630 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -50,6 +50,7 @@ public static class ServicesDI .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/DeadTorrentConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/DeadTorrentConfigRequest.cs new file mode 100644 index 00000000..3446bd42 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/DeadTorrentConfigRequest.cs @@ -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 Categories { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DeadTorrentConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DeadTorrentConfigResponse.cs new file mode 100644 index 00000000..d82be823 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DeadTorrentConfigResponse.cs @@ -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 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, + }; +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DownloadCleanerClientResponse.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DownloadCleanerClientResponse.cs index f23d41e9..4c7d11ba 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DownloadCleanerClientResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Responses/DownloadCleanerClientResponse.cs @@ -16,5 +16,7 @@ public sealed record DownloadCleanerClientResponse public UnlinkedConfigResponse? UnlinkedConfig { get; init; } + public DeadTorrentConfigResponse? DeadTorrentConfig { get; init; } + public OrphanedFilesConfigResponse? OrphanedFilesConfig { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs new file mode 100644 index 00000000..6b81b564 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs @@ -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 _logger; + private readonly DataContext _dataContext; + + public DeadTorrentConfigController( + ILogger logger, + DataContext dataContext) + { + _logger = logger; + _dataContext = dataContext; + } + + [HttpGet("{downloadClientId}")] + public async Task 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 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(); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs index bac96ba2..98e5690d 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs @@ -54,11 +54,15 @@ public sealed class DownloadCleanerConfigController : ControllerBase var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync(); var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync(); List allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync(); + List allDeadTorrentConfigs = await _dataContext.DeadTorrentConfigs.AsNoTracking().ToListAsync(); List allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync(); Dictionary unlinkedConfigsByClientId = allUnlinkedConfigs .GroupBy(u => u.DownloadClientConfigId) .ToDictionary(g => g.Key, g => g.First()); + Dictionary deadTorrentConfigsByClientId = allDeadTorrentConfigs + .GroupBy(d => d.DownloadClientConfigId) + .ToDictionary(g => g.Key, g => g.First()); Dictionary orphanedFilesConfigsByClientId = allOrphanedFilesConfigs .GroupBy(o => o.DownloadClientConfigId) .ToDictionary(g => g.Key, g => g.First()); @@ -70,6 +74,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase List 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, }); } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs index f6d897b2..b2767d06 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs @@ -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 { diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs index 39345aef..1a2cd9a6 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs @@ -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 { diff --git a/code/backend/Cleanuparr.Domain/Enums/EventType.cs b/code/backend/Cleanuparr.Domain/Enums/EventType.cs index 4e876880..f56acb5c 100644 --- a/code/backend/Cleanuparr.Domain/Enums/EventType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/EventType.cs @@ -7,6 +7,7 @@ public enum EventType DownloadingMetadataStrike, SlowSpeedStrike, SlowTimeStrike, + DeadTorrentStrike, QueueItemDeleted, DownloadCleaned, CategoryChanged, diff --git a/code/backend/Cleanuparr.Domain/Enums/StrikeType.cs b/code/backend/Cleanuparr.Domain/Enums/StrikeType.cs index fda33934..2a9c29ff 100644 --- a/code/backend/Cleanuparr.Domain/Enums/StrikeType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/StrikeType.cs @@ -7,4 +7,5 @@ public enum StrikeType FailedImport, SlowSpeed, SlowTime, + DeadTorrent, } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs index 67aa093c..07299944 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs @@ -856,6 +856,51 @@ public class QBitServiceDCTests : IClassFixture } } + 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(); + 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>(h => h.Contains("hash1")), "cleanuparr-dead"); + await _fixture.ClientWrapper.DidNotReceive() + .AddTorrentTagAsync(Arg.Any>(), Arg.Any()); + 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(); + 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>(h => h.Contains("hash1")), "cleanuparr-dead"); + await _fixture.ClientWrapper.DidNotReceive() + .SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "cleanuparr-dead", true); + } + } + public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs new file mode 100644 index 00000000..5a0db928 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs @@ -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(); + _downloadService = Substitute.For(); + + _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>(), _dataContext, _striker); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + private void AddConfig(bool enabled = true, ushort maxStrikes = 3, bool useTag = false, List? 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(); + torrent.Hash.Returns(hash); + torrent.Name.Returns($"Test {hash}"); + torrent.Category.Returns(category); + torrent.SeederCount.Returns(seederCount); + torrent.Tags.Returns(tags ?? Array.Empty()); + return torrent; + } + + [Fact] + public async Task NoConfig_DoesNothing() + { + var downloads = new List { 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 { 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(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + var downloads = new List { CreateTorrent("hash1", "movies", 0) }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any(), (ushort)3, StrikeType.DeadTorrent); + await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default); + } + + [Fact] + public async Task ZeroSeeders_AtThreshold_MovesToCategory() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(true); + var torrent = CreateTorrent("hash1", "movies", 0); + var downloads = new List { 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 { CreateTorrent("hash1", "movies", 5) }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any(), 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(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + var downloads = new List { CreateTorrent("hash1", "movies", null) }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } + + [Fact] + public async Task NegativeSeederCount_IsDead_Strikes() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + var downloads = new List { CreateTorrent("hash1", "movies", -1) }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } + + [Fact] + public async Task SkipsTorrentsNotInConfiguredCategories() + { + AddConfig(categories: ["movies"]); + var downloads = new List { 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 { 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 { 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); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs index bf8576e1..2c2f7400 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs @@ -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) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs index c9670636..b06c3080 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs @@ -53,6 +53,7 @@ public class DownloadCleanerTests : IDisposable _fixture.TimeProvider, _fixture.SeedingRulesService, _fixture.UnlinkedService, + _fixture.DeadTorrentService, _fixture.OrphanedFilesService ); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs index 5dada293..73309f3d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs @@ -49,6 +49,7 @@ public class DownloadCleanerIntegrationTests : IDisposable _fixture.TimeProvider, _fixture.SeedingRulesService, _fixture.UnlinkedService, + _fixture.DeadTorrentService, _fixture.OrphanedFilesService); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs index 6c2ba905..cff981c7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs @@ -60,6 +60,7 @@ public class IntegrationTestFixture : IDisposable public IHubContext 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>(), DataContext, HardLinkFileService); + DeadTorrentService = new DeadTorrentService( + Substitute.For>(), + DataContext, + Striker); OrphanedFilesService = new OrphanedFilesCleanupService( Substitute.For>(), DataContext, diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs index 72b0c663..d3ac50aa 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs @@ -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 SeedingRulesLogger { get; private set; } public ILogger UnlinkedLogger { get; private set; } @@ -75,6 +76,7 @@ public class JobHandlerFixture : IDisposable OrphanedFilesLogger = Substitute.For>(); SeedingRulesService = new SeedingRulesCleanupService(SeedingRulesLogger, DataContext); UnlinkedService = new UnlinkedDownloadsService(UnlinkedLogger, DataContext, HardLinkFileService); + DeadTorrentService = Substitute.For(); OrphanedFilesService = new OrphanedFilesCleanupService( OrphanedFilesLogger, DataContext, diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs index ee46970d..0ec6fa13 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs @@ -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()); + } + [Fact] public async Task NotifyStrike_WhenNoProviders_DoesNotThrow() { diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index aaf814be..9129139a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -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) }; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs new file mode 100644 index 00000000..535155d1 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs @@ -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; + +/// +public sealed class DeadTorrentService : IDeadTorrentService +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IStriker _striker; + + public DeadTorrentService( + ILogger logger, + DataContext dataContext, + IStriker striker) + { + _logger = logger; + _dataContext = dataContext; + _striker = striker; + } + + public async Task ProcessAsync(IDownloadService downloadService, List 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 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); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs new file mode 100644 index 00000000..d474b6fd --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs @@ -0,0 +1,19 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +/// 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. +/// +public interface IDeadTorrentService +{ + /// + /// Strikes torrents reporting zero seeders and moves those that reach the configured limit + /// to the target category/tag for the given client run. + /// + /// Download-client service for the current client. + /// The client's torrent items to evaluate. + Task ProcessAsync(IDownloadService downloadService, List clientDownloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index ed994df3..882a0a77 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -156,6 +156,22 @@ public partial class DelugeService } } + /// + 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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 7322720f..ce4190f4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -138,6 +138,9 @@ public abstract class DownloadService : IDownloadService /// public abstract Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig); + /// + public abstract Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + /// public abstract Task CreateCategoryAsync(string name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs index 83590c65..eb70eddd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs @@ -67,6 +67,14 @@ public interface IDownloadService : IDisposable /// The unlinked config for this download client. Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig); + /// + /// Moves a single torrent to the target category, or adds it as a tag/label when is set. + /// + /// The torrent to move. + /// The target category/tag. + /// When true, add a tag/label instead of changing the category (qBittorrent and Transmission). + Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + /// /// Deletes a download item. /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index b2edca59..3d049194 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -185,6 +185,25 @@ public partial class QBitService } } + /// + 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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 38e2ffb4..9472b1bf 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -150,6 +150,22 @@ public partial class RTorrentService } } + /// + 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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index 42a8c3a5..1ec4e88e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -168,6 +168,40 @@ public partial class TransmissionService } } + /// + 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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index 39fc8f4e..7942e020 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -133,6 +133,22 @@ public partial class UTorrentService } } + /// + 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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs index bcb0b77d..86f9e75e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs @@ -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 diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index 5960ac52..c5a90577 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -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); diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DeadTorrentConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DeadTorrentConfigTests.cs new file mode 100644 index 00000000..8abb91a0 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DeadTorrentConfigTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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(() => config.Validate()); + exception.Message.ShouldBe("Dead torrent max strikes must be at least 3"); + } +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index c3251306..24729dbf 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -51,7 +51,9 @@ public class DataContext : DbContext public DbSet RTorrentSeedingRules { get; set; } public DbSet UnlinkedConfigs { get; set; } - + + public DbSet DeadTorrentConfigs { get; set; } + public DbSet ArrConfigs { get; set; } public DbSet 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(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(entity => { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.Designer.cs new file mode 100644 index 00000000..3fbdd960 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.Designer.cs @@ -0,0 +1,2255 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260613142438_AddDeadTorrentConfig")] + partial class AddDeadTorrentConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("INTEGER") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + 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") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.cs new file mode 100644 index 00000000..24efdbaf --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddDeadTorrentConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "dead_torrent_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + download_client_config_id = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + target_category = table.Column(type: "TEXT", nullable: false), + use_tag = table.Column(type: "INTEGER", nullable: false), + max_strikes = table.Column(type: "INTEGER", nullable: false), + categories = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "dead_torrent_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index b43751f6..057d00cd 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("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("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") diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/DeadTorrentConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/DeadTorrentConfig.cs new file mode 100644 index 00000000..165d8e2b --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/DeadTorrentConfig.cs @@ -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; + + /// + /// Category/tag a dead torrent is moved to once it reaches . + /// + public string TargetCategory { get; set; } = "cleanuparr-dead"; + + /// + /// When true, add a tag/label instead of changing the category. Supported by qBittorrent and Transmission. + /// + public bool UseTag { get; set; } + + /// + /// Number of consecutive runs a torrent must report zero seeders before being moved. + /// + public ushort MaxStrikes { get; set; } + + /// + /// Source categories to scan for dead torrents. At least one must be specified. + /// + public List 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"); + } + } +} diff --git a/code/frontend/src/app/core/api/download-cleaner.api.ts b/code/frontend/src/app/core/api/download-cleaner.api.ts index 7450422a..7be2cd02 100644 --- a/code/frontend/src/app/core/api/download-cleaner.api.ts +++ b/code/frontend/src/app/core/api/download-cleaner.api.ts @@ -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): Observable { + updateUnlinkedConfig(clientId: string, config: UnlinkedConfigModel): Observable { return this.http.put(`/api/unlinked-config/${clientId}`, config); } + // Dead torrent config + updateDeadTorrentConfig(clientId: string, config: DeadTorrentConfigModel): Observable { + return this.http.put(`/api/dead-torrent-config/${clientId}`, config); + } + // Per-client orphaned files config - updateOrphanedFilesConfig(clientId: string, config: Partial): Observable { + updateOrphanedFilesConfig(clientId: string, config: OrphanedFilesConfig): Observable { return this.http.put(`/api/orphaned-files-config/${clientId}`, config); } } diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index ff3edd0b..bf419733 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -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', diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html index 3716aabe..11144b6e 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html @@ -201,6 +201,48 @@ + @if (isDeadTorrentCapableClient()) { + +
+ + @if (client.deadTorrentConfig?.enabled) { + + @if (isTagFilterableClient()) { + + } + + +
+ + + } +
+ + {{ deadTorrentSaved() ? 'Saved!' : 'Save' }} + +
+
+
+ } +
>({}); + private readonly deadTorrentSnapshots = signal>({}); // 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 = {}; + const deadTorrentSnapshots: Record = {}; const orphanedFilesSnapshots: Record = {}; 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 { - 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(field: K, value: UnlinkedConfigModel[K]): void { @@ -514,6 +585,44 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { }); } + // --- Dead torrent per-client config --- + + updateDeadTorrentField(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(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(); } } diff --git a/code/frontend/src/app/shared/models/download-cleaner-config.model.ts b/code/frontend/src/app/shared/models/download-cleaner-config.model.ts index 2c7df390..1e0e7b1c 100644 --- a/code/frontend/src/app/shared/models/download-cleaner-config.model.ts +++ b/code/frontend/src/app/shared/models/download-cleaner-config.model.ts @@ -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, diff --git a/docs/docs/configuration/download-cleaner/index.mdx b/docs/docs/configuration/download-cleaner/index.mdx index 37a38f8c..d41083cb 100644 --- a/docs/docs/configuration/download-cleaner/index.mdx +++ b/docs/docs/configuration/download-cleaner/index.mdx @@ -438,4 +438,60 @@ Categories to check for unlinked downloads. Only downloads in these categories w
+
+ +Dead Torrents + +

+ A dead torrent is one whose tracker reports no seeders for a prolonged period — typically because it was removed from the tracker, or the tracker host itself is gone — so it can never reach a ratio or seed-time threshold. Instead of deleting such torrents, this feature moves them to a target category (or adds a tag/label) so you can decide what to do next via a seeding rule for that category/tag. It runs as part of the Download Cleaner job. Configured per download client in the Dead Torrents accordion. +

+ + +A torrent is considered dead on a run when the client reports **no seeders** — a count of `0`, an unknown count (e.g. qBittorrent's `-1`), or no count at all because the tracker is unreachable. It is only moved after it has been dead for the configured number of consecutive runs (the strike count), and **strikes reset to zero as soon as the client reports seeders again**. Supported for **qBittorrent**, **Transmission**, **Deluge**, and **µTorrent** — not **rTorrent**, which does not report a seeder count. + + + + +When enabled, torrents in the configured categories that report no seeders for the configured number of consecutive runs are moved to the target category (or tagged). Create a seeding rule for that category/tag to control whether and when they are removed. + + + + + +The category a dead torrent is moved to (or the tag/label added when **Use Tag** is enabled). Create a seeding rule for this category/tag to manage the moved torrents. + + + + + +When enabled, adds a tag/label instead of changing the category, preserving the original category. Supported by **qBittorrent** (tags) and **Transmission** (labels). For Transmission, changing the category (with this option off) moves the files on disk, whereas using a label does not. + + + + + +The number of consecutive runs a torrent must report no seeders before it is moved. Minimum is `3`. Strikes reset to zero as soon as seeders are found again. + +Set this **high enough to ride out tracker downtime** so a temporarily-unreachable tracker isn't mistaken for a dead one. Choose it together with the Download Cleaner schedule — with an hourly schedule, `24` ≈ one day and `168` ≈ one week of being continuously dead. + + + + + +Source categories to scan for dead torrents. Only downloads in these categories are checked. The target category must not be one of these. + + + +
+ diff --git a/e2e/Makefile b/e2e/Makefile index a0c9d5b3..38c7b1da 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -10,7 +10,7 @@ up-api: down setup docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans app keycloak nginx up-dc: down setup - docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans app keycloak nginx qbittorrent transmission deluge utorrent rutorrent + docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans app keycloak nginx tracker qbittorrent transmission deluge utorrent rutorrent down: docker compose -f docker-compose.e2e.yml down -v diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 369b9dea..2607767f 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -48,6 +48,11 @@ services: depends_on: - app + tracker: + image: wiltonsr/opentracker:open + network_mode: host + restart: unless-stopped + qbittorrent: image: lscr.io/linuxserver/qbittorrent:4.6.7 network_mode: host @@ -110,6 +115,8 @@ services: platform: linux/amd64 ports: - "8083:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: UID: "1000" GID: "1000" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index dfc52f00..2827abb9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -15,12 +15,12 @@ export default defineConfig({ projects: [ { name: 'api', - testIgnore: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker)\.spec\.ts$/, + testIgnore: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker|dead-torrent-cleanup)\.spec\.ts$/, use: { browserName: 'chromium' }, }, { name: 'download-clients', - testMatch: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker)\.spec\.ts$/, + testMatch: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker|dead-torrent-cleanup)\.spec\.ts$/, use: { browserName: 'chromium' }, }, ], diff --git a/e2e/tests/dead-torrent-cleanup.spec.ts b/e2e/tests/dead-torrent-cleanup.spec.ts new file mode 100644 index 00000000..bac9a37e --- /dev/null +++ b/e2e/tests/dead-torrent-cleanup.spec.ts @@ -0,0 +1,280 @@ +import { test, expect } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + listDownloadClients, + deleteDownloadClient, + updateDownloadCleanerConfig, + getDownloadCleanerConfig, + updateDeadTorrentConfig, + triggerJob, +} from './helpers/app-api'; +import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; +import { TransmissionDriver } from './helpers/torrent-clients/transmission'; +import { DelugeDriver } from './helpers/torrent-clients/deluge'; +import { UTorrentDriver } from './helpers/torrent-clients/utorrent'; +import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; + +const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const CLIENT_DOWNLOADS = '/downloads'; +const TARGET = 'cleanuparr-dead'; +const MAX_STRIKES = 3; +const DEAD_ANNOUNCE = 'http://tracker.invalid/announce'; +const ALIVE_ANNOUNCE_HOST = 'http://127.0.0.1:6969/announce'; +const ALIVE_ANNOUNCE_BRIDGE = 'http://host.docker.internal:6969/announce'; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +interface DriverLike { + readonly typeName: string; + readonly cleanuparrHost: string; + readonly username?: string; + readonly password?: string; + ready(): Promise; + clearAllTorrents(): Promise; + listTorrents(): Promise>; +} + +const qbit = new QBittorrentDriver(); +const transmission = new TransmissionDriver(); +const deluge = new DelugeDriver(); +const utorrent = new UTorrentDriver(); + +/** + * One per distinct move path in DownloadService.ChangeTorrentCategoryAsync. + * Two scenarios may share the same physical client (e.g. qBittorrent category + * vs tag) — they use different source categories so they never overlap, and + * the physical client is prepared (ready/clear/reset) only once. + */ +interface Scenario { + key: string; + driver: DriverLike; + /** Host dir slug = the client's bind-mounted /downloads. */ + physicalSlug: string; + /** Source category the dead/alive torrents live in. */ + source: string; + useTag: boolean; + aliveAnnounce: string; + /** + * Whether a torrent the client is the sole seeder of can be told apart from a + * dead one. qBittorrent/Transmission/Deluge derive SeederCount from the tracker + * scrape, which counts the local seed, so a reachable-tracker solo seed reports + * >= 1 and is spared. uTorrent derives it from connectable seeders discovered in + * the swarm (the local instance is excluded and there are no other peers), so a + * solo self-seed reports 0 — indistinguishable from dead — and gets moved. + */ + soloSeedSparable: boolean; + /** Builds + adds a seeding torrent in this scenario's source category. */ + addSeeding(name: string, announce: string): Promise; + /** True once the torrent has been moved to the target category / tagged. */ + isMoved(infoHash: string): Promise; +} + +function buildTorrent(physicalSlug: string, name: string, announce: string, subdir = ''): { metainfo: Buffer; infoHash: string; name: string } { + const dir = subdir ? join(HOST_DOWNLOADS, physicalSlug, subdir) : join(HOST_DOWNLOADS, physicalSlug); + mkdirSync(dir, { recursive: true }); + chmodIgnoringEPERM(dir, 0o777); + const fx = buildFolderTorrent(dir, name, 32_768, announce); + return { metainfo: fx.metainfo, infoHash: fx.infoHash, name }; +} + +const scenarios: Scenario[] = [ + // qBittorrent — category mode (changes the torrent's category to the target). + { + key: 'qBittorrent (category)', driver: qbit, physicalSlug: 'qbittorrent', source: 'qb-cat', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('qbittorrent', name, announce); + await qbit.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'qb-cat', infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await qbit.getTorrentCategory(hash)) === TARGET; + }, + }, + // qBittorrent — tag mode (adds the target as a tag, category preserved). + { + key: 'qBittorrent (tag)', driver: qbit, physicalSlug: 'qbittorrent', source: 'qb-tag', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('qbittorrent', name, announce); + await qbit.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'qb-tag', infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await qbit.getTorrentTags(hash)).map((t) => t.toLowerCase()).includes(TARGET); + }, + }, + // Transmission — label/tag mode (adds the target as a label). + { + key: 'Transmission (label)', driver: transmission, physicalSlug: 'transmission', source: 't-label', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('transmission', name, announce, 't-label'); + await transmission.addSeedingTorrent({ metainfo: d.metainfo, savePath: `${CLIENT_DOWNLOADS}/t-label`, category: 't-label', infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await transmission.getTorrentLabels(hash)).map((l) => l.toLowerCase()).includes(TARGET); + }, + }, + // Transmission — category mode (relocates files; new dir ends with the target). + { + key: 'Transmission (category)', driver: transmission, physicalSlug: 'transmission', source: 't-loc', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('transmission', name, announce, 't-loc'); + await transmission.addSeedingTorrent({ metainfo: d.metainfo, savePath: `${CLIENT_DOWNLOADS}/t-loc`, category: 't-loc', infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + const dir = (await transmission.getTorrentDownloadDir(hash)) ?? ''; + return dir.replace(/\/$/, '').endsWith(`/${TARGET}`); + }, + }, + // Deluge — label. + { + key: 'Deluge', driver: deluge, physicalSlug: 'deluge', source: 'dl-src', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('deluge', name, announce); + await deluge.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'dl-src', name: d.name, infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await deluge.getTorrentLabel(hash)) === TARGET; + }, + }, + // µTorrent — label (bridge-networked → reaches opentracker via host.docker.internal). + { + key: 'uTorrent', driver: utorrent, physicalSlug: 'utorrent', source: 'ut-src', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_BRIDGE, soloSeedSparable: false, + async addSeeding(name, announce) { + const d = buildTorrent('utorrent', name, announce); + await utorrent.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'ut-src', name: d.name, infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await utorrent.getTorrentLabel(hash)) === TARGET; + }, + }, +]; + +async function waitForRegistered(driver: DriverLike, infoHash: string, timeoutMs = 20_000): Promise { + const want = infoHash.toLowerCase(); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ((await driver.listTorrents()).some((t) => t.hash.toLowerCase() === want)) return; + await sleep(500); + } + throw new Error(`torrent ${infoHash} never registered with ${driver.typeName}`); +} + +/** + * Dead torrent cleanup e2e covering every move path: a seeding torrent whose + * tracker is unreachable (no seeders) is moved/tagged after MAX_STRIKES runs, + * while a torrent that has seeders (via opentracker) is left untouched. + */ +test.describe.serial('Dead torrent cleanup', () => { + let token: string; + const dead = new Map(); + const alive = new Map(); + const prepared = new Set(); + + test.beforeAll(async () => { + token = await loginAndGetToken(); + for (const c of await listDownloadClients(token)) { + await deleteDownloadClient(token, c.id); + } + const dc = await (await getDownloadCleanerConfig(token)).json(); + await updateDownloadCleanerConfig(token, { + enabled: true, + cronExpression: dc.cronExpression || '0 0 * * * ?', + useAdvancedScheduling: dc.useAdvancedScheduling ?? false, + ignoredDownloads: [], + }); + mkdirSync(HOST_DOWNLOADS, { recursive: true }); + }); + + test.afterAll(async () => { + // The Transmission "category" move physically relocates the struck torrent + // into a `cleanuparr-dead/` directory the container creates as its PUID. On + // CI the Playwright runner runs as a different uid and cannot rmdir those + // container-owned files, which breaks resetDirectory() in later specs. + // Delete the torrents *with* their data so the container removes the files + // it created, leaving only runner-owned (removable) directories behind. + if (prepared.has('transmission')) { + await transmission.clearAllTorrents(true).catch(() => {}); + } + }); + + for (const s of scenarios) { + test(`${s.key}: set up dead + alive seeding torrents`, async () => { + test.setTimeout(120_000); + + // Prepare the physical client once (multiple scenarios may share it). + if (!prepared.has(s.physicalSlug)) { + resetDirectory(join(HOST_DOWNLOADS, s.physicalSlug)); + await s.driver.ready(); + await s.driver.clearAllTorrents(); + prepared.add(s.physicalSlug); + } + + const createRes = await createDownloadClient(token, { + enabled: true, + name: `${s.key} dead e2e`, + typeName: s.driver.typeName, + type: 'Torrent', + host: s.driver.cleanuparrHost, + username: s.driver.username ?? '', + password: s.driver.password ?? '', + }); + expect(createRes.status).toBeGreaterThanOrEqual(200); + expect(createRes.status).toBeLessThan(300); + const clientId = (await createRes.json()).id; + + const cfg = await updateDeadTorrentConfig(token, clientId, { + enabled: true, + targetCategory: TARGET, + useTag: s.useTag, + maxStrikes: MAX_STRIKES, + categories: [s.source], + }); + expect(cfg.status).toBe(200); + + const deadHash = await s.addSeeding(`dead-${s.physicalSlug}-${s.source}`, DEAD_ANNOUNCE); + const aliveHash = await s.addSeeding(`alive-${s.physicalSlug}-${s.source}`, s.aliveAnnounce); + dead.set(s.key, deadHash); + alive.set(s.key, aliveHash); + + await waitForRegistered(s.driver, deadHash); + await waitForRegistered(s.driver, aliveHash); + expect(await s.isMoved(deadHash)).toBe(false); + expect(await s.isMoved(aliveHash)).toBe(false); + }); + } + + test('moves dead torrents but leaves seeded torrents untouched', async () => { + test.setTimeout(180_000); + + const active = () => scenarios.filter((s) => dead.has(s.key)); + const moved = new Map(); + + for (let run = 0; run < MAX_STRIKES + 3; run++) { + if (active().every((s) => moved.get(s.key))) break; + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + await sleep(13_000); // ride out the job's ~10s Arr-sync delay + processing + for (const s of active()) { + if (!moved.get(s.key)) { + moved.set(s.key, await s.isMoved(dead.get(s.key)!)); + } + } + } + + for (const s of active()) { + expect(moved.get(s.key), `${s.key}: dead torrent was not moved/tagged`).toBe(true); + if (s.soloSeedSparable) { + expect(await s.isMoved(alive.get(s.key)!), `${s.key}: seeded torrent was wrongly moved`).toBe(false); + } + } + }); +}); diff --git a/e2e/tests/dead-torrent-config-api.spec.ts b/e2e/tests/dead-torrent-config-api.spec.ts new file mode 100644 index 00000000..bc356e54 --- /dev/null +++ b/e2e/tests/dead-torrent-config-api.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; +import { + loginAndGetToken, + createDownloadClient, + deleteDownloadClient, + getDeadTorrentConfig, + updateDeadTorrentConfig, + getDownloadCleanerConfig, +} from './helpers/app-api'; + +test.describe.serial('Dead Torrent Config API', () => { + let token: string; + let clientId: string; + let rtorrentClientId: string; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + const res = await createDownloadClient(token, { + enabled: false, + name: 'e2e-dead-qbit', + typeName: 'qBittorrent', + type: 'Torrent', + host: 'http://localhost:9999', + }); + expect(res.status).toBe(201); + clientId = (await res.json()).id; + + const rt = await createDownloadClient(token, { + enabled: false, + name: 'e2e-dead-rtorrent', + typeName: 'rTorrent', + type: 'Torrent', + host: 'http://localhost:9998', + }); + expect(rt.status).toBe(201); + rtorrentClientId = (await rt.json()).id; + }); + + test.afterAll(async () => { + if (clientId) { + await deleteDownloadClient(token, clientId); + } + if (rtorrentClientId) { + await deleteDownloadClient(token, rtorrentClientId); + } + }); + + test('returns no config for a new client', async () => { + const res = await getDeadTorrentConfig(token, clientId); + // No config yet: 204 No Content, or 200 with null/empty body. + expect([200, 204]).toContain(res.status); + const body = await res.text(); + expect(body === '' || body === 'null').toBe(true); + }); + + test('creates and round-trips a valid config', async () => { + const update = await updateDeadTorrentConfig(token, clientId, { + enabled: true, + targetCategory: 'cleanuparr-dead', + useTag: true, + maxStrikes: 5, + categories: ['movies', 'tv'], + }); + expect(update.status).toBe(200); + + const res = await getDeadTorrentConfig(token, clientId); + const config = await res.json(); + expect(config.enabled).toBe(true); + expect(config.targetCategory).toBe('cleanuparr-dead'); + expect(config.useTag).toBe(true); + expect(config.maxStrikes).toBe(5); + expect(config.categories).toEqual(['movies', 'tv']); + }); + + test('surfaces the config in the download cleaner config', async () => { + const res = await getDownloadCleanerConfig(token); + const body = await res.json(); + const client = body.clients.find((c: { downloadClientId: string }) => c.downloadClientId === clientId); + expect(client).toBeDefined(); + expect(client.deadTorrentConfig).not.toBeNull(); + expect(client.deadTorrentConfig.maxStrikes).toBe(5); + expect(client.deadTorrentConfig.categories).toEqual(['movies', 'tv']); + }); + + test('rejects strikes below the minimum of 3', async () => { + const res = await updateDeadTorrentConfig(token, clientId, { + enabled: true, + targetCategory: 'cleanuparr-dead', + useTag: false, + maxStrikes: 2, + categories: ['movies'], + }); + expect(res.status).toBe(400); + }); + + test('rejects the target category being one of the source categories', async () => { + const res = await updateDeadTorrentConfig(token, clientId, { + enabled: true, + targetCategory: 'movies', + useTag: false, + maxStrikes: 3, + categories: ['movies'], + }); + expect(res.status).toBe(400); + }); + + test('rejects empty categories when enabled', async () => { + const res = await updateDeadTorrentConfig(token, clientId, { + enabled: true, + targetCategory: 'cleanuparr-dead', + useTag: false, + maxStrikes: 3, + categories: [], + }); + expect(res.status).toBe(400); + }); + + test('allows disabling regardless of other fields', async () => { + const res = await updateDeadTorrentConfig(token, clientId, { + enabled: false, + targetCategory: '', + useTag: false, + maxStrikes: 0, + categories: [], + }); + expect(res.status).toBe(200); + }); + + test('rejects enabling for rTorrent', async () => { + const res = await updateDeadTorrentConfig(token, rtorrentClientId, { + enabled: true, + targetCategory: 'cleanuparr-dead', + useTag: false, + maxStrikes: 3, + categories: ['movies'], + }); + expect(res.status).toBe(400); + }); +}); diff --git a/e2e/tests/helpers/app-api.ts b/e2e/tests/helpers/app-api.ts index 36e66856..9fde3d20 100644 --- a/e2e/tests/helpers/app-api.ts +++ b/e2e/tests/helpers/app-api.ts @@ -266,6 +266,27 @@ export async function updateUnlinkedConfig( }); } +export async function getDeadTorrentConfig(accessToken: string, downloadClientId: string): Promise { + return fetch(`${API}/api/dead-torrent-config/${downloadClientId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function updateDeadTorrentConfig( + accessToken: string, + downloadClientId: string, + config: Record, +): Promise { + return fetch(`${API}/api/dead-torrent-config/${downloadClientId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(config), + }); +} + export async function reorderSeedingRules( accessToken: string, downloadClientId: string, diff --git a/e2e/tests/helpers/torrent-clients/deluge.ts b/e2e/tests/helpers/torrent-clients/deluge.ts index afae7820..53846c9f 100644 --- a/e2e/tests/helpers/torrent-clients/deluge.ts +++ b/e2e/tests/helpers/torrent-clients/deluge.ts @@ -93,6 +93,42 @@ export class DelugeDriver implements TorrentClientDriver { ]); } + /** + * Add a started, complete (seed_mode) torrent and assign it a label. + * Enables the Label plugin and creates the label if needed. + */ + async addSeedingTorrent({ metainfo, savePath, category, name }: { metainfo: Buffer; savePath: string; category: string; name: string; infoHash: string }): Promise { + await this.ensureLabel(category); + const hash = await this.call('core.add_torrent_file', [ + `${name}.torrent`, + metainfo.toString('base64'), + { + download_location: savePath, + add_paused: false, + seed_mode: true, + }, + ]); + await this.call('label.set_torrent', [hash, category]); + } + + private async ensureLabel(label: string): Promise { + await this.call('core.enable_plugin', ['Label']); + try { + await this.call('label.add', [label]); + } catch { + // label already exists — ignore + } + } + + /** Returns the torrent's Label-plugin label, or undefined. */ + async getTorrentLabel(infoHash: string): Promise { + const result = await this.call>( + 'core.get_torrents_status', + [{ id: [infoHash] }, ['label']], + ); + return result?.[infoHash]?.label || undefined; + } + async deleteTorrent(infoHash: string): Promise { // remove_torrent signature: (torrent_id, remove_data: bool) await this.call('core.remove_torrent', [infoHash, false]); diff --git a/e2e/tests/helpers/torrent-clients/qbittorrent.ts b/e2e/tests/helpers/torrent-clients/qbittorrent.ts index c7ad7393..3861bd8e 100644 --- a/e2e/tests/helpers/torrent-clients/qbittorrent.ts +++ b/e2e/tests/helpers/torrent-clients/qbittorrent.ts @@ -76,6 +76,51 @@ export class QBittorrentDriver implements TorrentClientDriver { } } + /** + * Add a torrent that is immediately seeding (started, hash-check skipped, data present) + * and assigned to a category. Used by the dead-torrent spec. + */ + async addSeedingTorrent({ metainfo, savePath, category }: { metainfo: Buffer; savePath: string; category: string; infoHash: string }): Promise { + const form = new FormData(); + form.append('torrents', new Blob([new Uint8Array(metainfo)]), 'torrent.torrent'); + form.append('savepath', savePath); + form.append('paused', 'false'); + form.append('skip_checking', 'true'); + form.append('autoTMM', 'false'); + form.append('category', category); + const res = await fetch(`${this.directHost}/api/v2/torrents/add`, { + method: 'POST', + headers: this.cookie ? { Cookie: this.cookie } : undefined, + body: form, + }); + if (!res.ok) { + throw new Error(`qBittorrent add (seeding) failed: ${res.status} ${await res.text()}`); + } + } + + /** Returns the torrent's category, or undefined if not found. */ + async getTorrentCategory(infoHash: string): Promise { + const t = await this.getTorrent(infoHash); + return t?.category; + } + + /** Returns the torrent's tags (comma-separated in qBit). */ + async getTorrentTags(infoHash: string): Promise { + const t = await this.getTorrent(infoHash); + return (t?.tags ?? '').split(',').map((s) => s.trim()).filter((s) => s.length > 0); + } + + private async getTorrent(infoHash: string): Promise<{ category?: string; tags?: string; state?: string } | undefined> { + const res = await fetch(`${this.directHost}/api/v2/torrents/info?hashes=${infoHash.toLowerCase()}`, { + headers: this.cookie ? { Cookie: this.cookie } : undefined, + }); + if (!res.ok) { + throw new Error(`qBittorrent info failed: ${res.status}`); + } + const items: Array<{ hash: string; category?: string; tags?: string; state?: string }> = await res.json(); + return items.find((t) => t.hash.toLowerCase() === infoHash.toLowerCase()); + } + async deleteTorrent(infoHash: string): Promise { const body = new URLSearchParams({ hashes: infoHash, deleteFiles: 'false' }); const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, { diff --git a/e2e/tests/helpers/torrent-clients/transmission.ts b/e2e/tests/helpers/torrent-clients/transmission.ts index 58af28ed..aa8d033b 100644 --- a/e2e/tests/helpers/torrent-clients/transmission.ts +++ b/e2e/tests/helpers/torrent-clients/transmission.ts @@ -84,6 +84,33 @@ export class TransmissionDriver implements TorrentClientDriver { void infoHash; } + /** + * Add a started torrent whose complete data is already on disk under `savePath`. + * Transmission's "category" (as Cleanuparr sees it) is the last segment of the + * download dir, so `savePath` should end with the desired source category. + */ + async addSeedingTorrent({ metainfo, savePath }: { metainfo: Buffer; savePath: string; category: string; infoHash: string }): Promise { + await this.call('torrent-add', { + metainfo: metainfo.toString('base64'), + 'download-dir': savePath, + paused: false, + }); + } + + /** Returns the torrent's labels (Transmission's tag equivalent). */ + async getTorrentLabels(infoHash: string): Promise { + const args = await this.call('torrent-get', { ids: [infoHash], fields: ['hashString', 'labels'] }); + const t = (args.torrents ?? [])[0]; + return (t?.labels ?? []) as string[]; + } + + /** Returns the torrent's current download directory (changes when category mode relocates it). */ + async getTorrentDownloadDir(infoHash: string): Promise { + const args = await this.call('torrent-get', { ids: [infoHash], fields: ['hashString', 'downloadDir'] }); + const t = (args.torrents ?? [])[0]; + return t?.downloadDir as string | undefined; + } + async deleteTorrent(infoHash: string): Promise { await this.call('torrent-remove', { ids: [infoHash], @@ -91,12 +118,12 @@ export class TransmissionDriver implements TorrentClientDriver { }); } - async clearAllTorrents(): Promise { + async clearAllTorrents(deleteData = false): Promise { const all = await this.listTorrents(); if (all.length === 0) return; await this.call('torrent-remove', { ids: all.map((t) => t.hash), - 'delete-local-data': false, + 'delete-local-data': deleteData, }); } diff --git a/e2e/tests/helpers/torrent-clients/utorrent.ts b/e2e/tests/helpers/torrent-clients/utorrent.ts index 920732ba..9f324ecd 100644 --- a/e2e/tests/helpers/torrent-clients/utorrent.ts +++ b/e2e/tests/helpers/torrent-clients/utorrent.ts @@ -83,6 +83,39 @@ export class UTorrentDriver implements TorrentClientDriver { } } + /** + * Add a torrent (data already present under the client's download dir, so it + * seeds immediately) and assign it a label. + */ + async addSeedingTorrent({ metainfo, name, category, infoHash }: { metainfo: Buffer; savePath: string; category: string; name: string; infoHash: string }): Promise { + const form = new FormData(); + form.append('torrent_file', new Blob([new Uint8Array(metainfo)]), `${name}.torrent`); + const addUrl = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=add-file`; + const res = await fetch(addUrl, { method: 'POST', headers: this.requestHeaders(), body: form }); + if (!res.ok) { + throw new Error(`uTorrent add (seeding): ${res.status} ${await res.text()}`); + } + const labelUrl = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=setprops&hash=${infoHash.toUpperCase()}&s=label&v=${encodeURIComponent(category)}`; + const labelRes = await fetch(labelUrl, { headers: this.requestHeaders() }); + if (!labelRes.ok) { + throw new Error(`uTorrent setprops label: ${labelRes.status}`); + } + } + + /** Returns the torrent's label (index 11 in the list row), or undefined. */ + async getTorrentLabel(infoHash: string): Promise { + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&list=1`; + const res = await fetch(url, { headers: this.requestHeaders() }); + if (!res.ok) { + throw new Error(`uTorrent list: ${res.status}`); + } + const body: { torrents?: unknown[][] } = await res.json(); + const want = infoHash.toLowerCase(); + const row = (body.torrents ?? []).find((r) => String(r[0]).toLowerCase() === want); + const label = row ? String(row[11]) : ''; + return label.length > 0 ? label : undefined; + } + async deleteTorrent(infoHash: string): Promise { // `remove` removes the torrent from the client without touching files; // `removedata` / `removedatatorrent` delete data and torrent file. diff --git a/e2e/tests/helpers/torrent-fixtures.ts b/e2e/tests/helpers/torrent-fixtures.ts index 560c7cbb..5b98e95d 100644 --- a/e2e/tests/helpers/torrent-fixtures.ts +++ b/e2e/tests/helpers/torrent-fixtures.ts @@ -60,7 +60,7 @@ export interface GeneratedTorrent { * @param name - top-level folder name (also the torrent's `info.name`) * @param sizeBytes - total size of the inner data file */ -export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 32_768): GeneratedTorrent { +export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 32_768, announce = 'http://tracker.invalid/announce'): GeneratedTorrent { const contentPath = join(savePath, name); mkdirSync(contentPath, { recursive: true }); chmodIgnoringEPERM(contentPath, 0o777); @@ -98,7 +98,7 @@ export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 3 private: 1, }; const metainfo = bencode({ - announce: 'http://tracker.invalid/announce', + announce, 'created by': 'cleanuparr-e2e', 'creation date': 0, info,