mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-23 04:59:46 -04:00
Add dead torrent handling (#627)
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class DeadTorrentConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DeadTorrentConfigController _controller;
|
||||
|
||||
public DeadTorrentConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
|
||||
_controller = new DeadTorrentConfigController(logger, _dataContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static DeadTorrentConfigRequest ValidRequest(
|
||||
bool enabled = true,
|
||||
string targetCategory = "cleanuparr-dead",
|
||||
bool useTag = false,
|
||||
ushort maxStrikes = 3,
|
||||
List<string>? categories = null)
|
||||
=> new()
|
||||
{
|
||||
Enabled = enabled,
|
||||
TargetCategory = targetCategory,
|
||||
UseTag = useTag,
|
||||
MaxStrikes = maxStrikes,
|
||||
Categories = categories ?? ["movies"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ValidRequest_PersistsConfig()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.MaxStrikes.ShouldBe((ushort)5);
|
||||
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ThenGet_RoundTrips()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
|
||||
|
||||
var result = await _controller.GetDeadTorrentConfig(client.Id);
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
|
||||
config.UseTag.ShouldBeTrue();
|
||||
config.MaxStrikes.ShouldBe((ushort)4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ public static class ServicesDI
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
|
||||
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
|
||||
.AddScoped<IDeadTorrentService, DeadTorrentService>()
|
||||
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
|
||||
.AddScoped<Seeker>()
|
||||
.AddScoped<CustomFormatScoreSyncer>()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record DeadTorrentConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string TargetCategory { get; init; } = "cleanuparr-dead";
|
||||
|
||||
public bool UseTag { get; init; }
|
||||
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public List<string> Categories { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
|
||||
public sealed record DeadTorrentConfigResponse
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public required string TargetCategory { get; init; }
|
||||
|
||||
public bool UseTag { get; init; }
|
||||
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public required List<string> Categories { get; init; }
|
||||
|
||||
public static DeadTorrentConfigResponse From(DeadTorrentConfig config) => new()
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
TargetCategory = config.TargetCategory,
|
||||
UseTag = config.UseTag,
|
||||
MaxStrikes = config.MaxStrikes,
|
||||
Categories = config.Categories,
|
||||
};
|
||||
}
|
||||
@@ -16,5 +16,7 @@ public sealed record DownloadCleanerClientResponse
|
||||
|
||||
public UnlinkedConfigResponse? UnlinkedConfig { get; init; }
|
||||
|
||||
public DeadTorrentConfigResponse? DeadTorrentConfig { get; init; }
|
||||
|
||||
public OrphanedFilesConfigResponse? OrphanedFilesConfig { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dead-torrent-config")]
|
||||
[Authorize]
|
||||
public class DeadTorrentConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DeadTorrentConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public DeadTorrentConfigController(
|
||||
ILogger<DeadTorrentConfigController> logger,
|
||||
DataContext dataContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
[HttpGet("{downloadClientId}")]
|
||||
public async Task<IActionResult> GetDeadTorrentConfig(Guid downloadClientId)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
var config = await _dataContext.DeadTorrentConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
|
||||
|
||||
return Ok(config is null ? null : DeadTorrentConfigResponse.From(config));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{downloadClientId}")]
|
||||
public async Task<IActionResult> UpdateDeadTorrentConfig(Guid downloadClientId, [FromBody] DeadTorrentConfigRequest dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent)
|
||||
{
|
||||
return BadRequest(new { Message = "Dead torrent handling is not supported for rTorrent (no seeder count available)" });
|
||||
}
|
||||
|
||||
var existing = await _dataContext.DeadTorrentConfigs
|
||||
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new DeadTorrentConfig
|
||||
{
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
};
|
||||
_dataContext.DeadTorrentConfigs.Add(existing);
|
||||
}
|
||||
|
||||
existing.Enabled = dto.Enabled;
|
||||
existing.TargetCategory = dto.TargetCategory;
|
||||
existing.UseTag = dto.UseTag;
|
||||
existing.MaxStrikes = dto.MaxStrikes;
|
||||
existing.Categories = dto.Categories;
|
||||
|
||||
existing.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated dead torrent config for client {ClientId}", downloadClientId);
|
||||
|
||||
return Ok(DeadTorrentConfigResponse.From(existing));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,15 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
|
||||
List<UnlinkedConfig> allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
|
||||
List<DeadTorrentConfig> allDeadTorrentConfigs = await _dataContext.DeadTorrentConfigs.AsNoTracking().ToListAsync();
|
||||
List<OrphanedFilesConfig> allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync();
|
||||
|
||||
Dictionary<Guid, UnlinkedConfig> unlinkedConfigsByClientId = allUnlinkedConfigs
|
||||
.GroupBy(u => u.DownloadClientConfigId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
Dictionary<Guid, DeadTorrentConfig> deadTorrentConfigsByClientId = allDeadTorrentConfigs
|
||||
.GroupBy(d => d.DownloadClientConfigId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
Dictionary<Guid, OrphanedFilesConfig> orphanedFilesConfigsByClientId = allOrphanedFilesConfigs
|
||||
.GroupBy(o => o.DownloadClientConfigId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
@@ -70,6 +74,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
List<ISeedingRule> seedingRules = SeedingRuleHelper
|
||||
.FilterForClient(client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
|
||||
unlinkedConfigsByClientId.TryGetValue(client.Id, out UnlinkedConfig? unlinkedConfig);
|
||||
deadTorrentConfigsByClientId.TryGetValue(client.Id, out DeadTorrentConfig? deadTorrentConfig);
|
||||
orphanedFilesConfigsByClientId.TryGetValue(client.Id, out OrphanedFilesConfig? orphanedFilesConfig);
|
||||
|
||||
clients.Add(new DownloadCleanerClientResponse
|
||||
@@ -80,6 +85,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
DownloadClientTypeName = client.TypeName,
|
||||
SeedingRules = seedingRules.Select(SeedingRuleResponse.From).ToList(),
|
||||
UnlinkedConfig = unlinkedConfig is not null ? UnlinkedConfigResponse.From(unlinkedConfig) : null,
|
||||
DeadTorrentConfig = deadTorrentConfig is not null ? DeadTorrentConfigResponse.From(deadTorrentConfig) : null,
|
||||
OrphanedFilesConfig = orphanedFilesConfig is not null ? OrphanedFilesConfigResponse.From(orphanedFilesConfig) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ public enum EventType
|
||||
DownloadingMetadataStrike,
|
||||
SlowSpeedStrike,
|
||||
SlowTimeStrike,
|
||||
DeadTorrentStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged,
|
||||
|
||||
@@ -7,4 +7,5 @@ public enum StrikeType
|
||||
FailedImport,
|
||||
SlowSpeed,
|
||||
SlowTime,
|
||||
DeadTorrent,
|
||||
}
|
||||
@@ -856,6 +856,51 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeTorrentCategoryAsync_Tests : QBitServiceDCTests
|
||||
{
|
||||
public ChangeTorrentCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CategoryMode_SetsCategory_AndPublishes()
|
||||
{
|
||||
var sut = _fixture.CreateSut();
|
||||
var torrent = Substitute.For<ITorrentItemWrapper>();
|
||||
torrent.Hash.Returns("hash1");
|
||||
torrent.Name.Returns("Test");
|
||||
torrent.Category.Returns("movies");
|
||||
|
||||
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
|
||||
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetTorrentCategoryAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.AddTorrentTagAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
|
||||
await _fixture.EventPublisher.Received(1)
|
||||
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagMode_AddsTag_AndPublishes()
|
||||
{
|
||||
var sut = _fixture.CreateSut();
|
||||
var torrent = Substitute.For<ITorrentItemWrapper>();
|
||||
torrent.Hash.Returns("hash1");
|
||||
torrent.Name.Returns("Test");
|
||||
torrent.Category.Returns("movies");
|
||||
|
||||
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true);
|
||||
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
|
||||
await _fixture.EventPublisher.Received(1)
|
||||
.PublishCategoryChanged("movies", "cleanuparr-dead", true);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
public sealed class DeadTorrentServiceTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IStriker _striker;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly DownloadClientConfig _clientConfig;
|
||||
private readonly DeadTorrentService _sut;
|
||||
|
||||
public DeadTorrentServiceTests()
|
||||
{
|
||||
_dataContext = TestDataContextFactory.Create(seedData: false);
|
||||
_striker = Substitute.For<IStriker>();
|
||||
_downloadService = Substitute.For<IDownloadService>();
|
||||
|
||||
_clientConfig = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
};
|
||||
_downloadService.ClientConfig.Returns(_clientConfig);
|
||||
|
||||
_dataContext.DownloadClients.Add(_clientConfig);
|
||||
_dataContext.SaveChanges();
|
||||
|
||||
_sut = new DeadTorrentService(Substitute.For<ILogger<DeadTorrentService>>(), _dataContext, _striker);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void AddConfig(bool enabled = true, ushort maxStrikes = 3, bool useTag = false, List<string>? categories = null)
|
||||
{
|
||||
_dataContext.DeadTorrentConfigs.Add(new DeadTorrentConfig
|
||||
{
|
||||
DownloadClientConfigId = _clientConfig.Id,
|
||||
Enabled = enabled,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
UseTag = useTag,
|
||||
MaxStrikes = maxStrikes,
|
||||
Categories = categories ?? ["movies"],
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private static ITorrentItemWrapper CreateTorrent(string hash, string category, int? seederCount, string[]? tags = null)
|
||||
{
|
||||
var torrent = Substitute.For<ITorrentItemWrapper>();
|
||||
torrent.Hash.Returns(hash);
|
||||
torrent.Name.Returns($"Test {hash}");
|
||||
torrent.Category.Returns(category);
|
||||
torrent.SeederCount.Returns(seederCount);
|
||||
torrent.Tags.Returns(tags ?? Array.Empty<string>());
|
||||
return torrent;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoConfig_DoesNothing()
|
||||
{
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_DoesNothing()
|
||||
{
|
||||
AddConfig(enabled: false);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroSeeders_BelowThreshold_StrikesButDoesNotMove()
|
||||
{
|
||||
AddConfig();
|
||||
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
|
||||
.Returns(false);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
|
||||
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroSeeders_AtThreshold_MovesToCategory()
|
||||
{
|
||||
AddConfig();
|
||||
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
|
||||
.Returns(true);
|
||||
var torrent = CreateTorrent("hash1", "movies", 0);
|
||||
var downloads = new List<ITorrentItemWrapper> { torrent };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _downloadService.Received(1).ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithSeeders_ResetsStrikesAndDoesNotMove()
|
||||
{
|
||||
AddConfig();
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 5) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any<string>(), StrikeType.DeadTorrent);
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnavailableSeederCount_IsDead_Strikes()
|
||||
{
|
||||
AddConfig();
|
||||
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
|
||||
.Returns(false);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", null) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
|
||||
await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeSeederCount_IsDead_Strikes()
|
||||
{
|
||||
AddConfig();
|
||||
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
|
||||
.Returns(false);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", -1) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any<string>(), (ushort)3, StrikeType.DeadTorrent);
|
||||
await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsNotInConfiguredCategories()
|
||||
{
|
||||
AddConfig(categories: ["movies"]);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "tv", 0) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsAlreadyInTargetCategory()
|
||||
{
|
||||
AddConfig();
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "cleanuparr-dead", 0) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsAlreadyTagged_WhenUseTag()
|
||||
{
|
||||
AddConfig(useTag: true);
|
||||
var downloads = new List<ITorrentItemWrapper> { CreateTorrent("hash1", "movies", 0, tags: ["cleanuparr-dead"]) };
|
||||
|
||||
await _sut.ProcessAsync(_downloadService, downloads);
|
||||
|
||||
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
|
||||
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +53,7 @@ public class DownloadCleanerTests : IDisposable
|
||||
_fixture.TimeProvider,
|
||||
_fixture.SeedingRulesService,
|
||||
_fixture.UnlinkedService,
|
||||
_fixture.DeadTorrentService,
|
||||
_fixture.OrphanedFilesService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
_fixture.TimeProvider,
|
||||
_fixture.SeedingRulesService,
|
||||
_fixture.UnlinkedService,
|
||||
_fixture.DeadTorrentService,
|
||||
_fixture.OrphanedFilesService);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ public class IntegrationTestFixture : IDisposable
|
||||
public IHubContext<AppHub> HubContext { get; private set; }
|
||||
public ISeedingRulesCleanupService SeedingRulesService { get; private set; } = null!;
|
||||
public IUnlinkedDownloadsService UnlinkedService { get; private set; } = null!;
|
||||
public IDeadTorrentService DeadTorrentService { get; private set; } = null!;
|
||||
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } = null!;
|
||||
|
||||
// State
|
||||
@@ -146,6 +147,10 @@ public class IntegrationTestFixture : IDisposable
|
||||
Substitute.For<ILogger<UnlinkedDownloadsService>>(),
|
||||
DataContext,
|
||||
HardLinkFileService);
|
||||
DeadTorrentService = new DeadTorrentService(
|
||||
Substitute.For<ILogger<DeadTorrentService>>(),
|
||||
DataContext,
|
||||
Striker);
|
||||
OrphanedFilesService = new OrphanedFilesCleanupService(
|
||||
Substitute.For<ILogger<OrphanedFilesCleanupService>>(),
|
||||
DataContext,
|
||||
|
||||
@@ -35,6 +35,7 @@ public class JobHandlerFixture : IDisposable
|
||||
public FakeTimeProvider TimeProvider { get; private set; }
|
||||
public ISeedingRulesCleanupService SeedingRulesService { get; private set; }
|
||||
public IUnlinkedDownloadsService UnlinkedService { get; private set; }
|
||||
public IDeadTorrentService DeadTorrentService { get; private set; }
|
||||
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; }
|
||||
public ILogger<SeedingRulesCleanupService> SeedingRulesLogger { get; private set; }
|
||||
public ILogger<UnlinkedDownloadsService> UnlinkedLogger { get; private set; }
|
||||
@@ -75,6 +76,7 @@ public class JobHandlerFixture : IDisposable
|
||||
OrphanedFilesLogger = Substitute.For<ILogger<OrphanedFilesCleanupService>>();
|
||||
SeedingRulesService = new SeedingRulesCleanupService(SeedingRulesLogger, DataContext);
|
||||
UnlinkedService = new UnlinkedDownloadsService(UnlinkedLogger, DataContext, HardLinkFileService);
|
||||
DeadTorrentService = Substitute.For<IDeadTorrentService>();
|
||||
OrphanedFilesService = new OrphanedFilesCleanupService(
|
||||
OrphanedFilesLogger,
|
||||
DataContext,
|
||||
|
||||
@@ -157,6 +157,15 @@ public class NotificationPublisherTests
|
||||
await _configService.Received(1).GetProvidersForEventAsync(expectedEventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithDeadTorrent_SendsNoNotification_AndDoesNotThrow()
|
||||
{
|
||||
// Act & Assert
|
||||
await _publisher.NotifyStrike(StrikeType.DeadTorrent, 3);
|
||||
|
||||
await _configService.DidNotReceive().GetProvidersForEventAsync(Arg.Any<NotificationEventType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
|
||||
|
||||
/// <inheritdoc cref="IDeadTorrentService" />
|
||||
public sealed class DeadTorrentService : IDeadTorrentService
|
||||
{
|
||||
private readonly ILogger<DeadTorrentService> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IStriker _striker;
|
||||
|
||||
public DeadTorrentService(
|
||||
ILogger<DeadTorrentService> logger,
|
||||
DataContext dataContext,
|
||||
IStriker striker)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
|
||||
{
|
||||
DeadTorrentConfig? config = await _dataContext.DeadTorrentConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadService.ClientConfig.Id);
|
||||
|
||||
if (config is not { Enabled: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.Categories.Count is 0)
|
||||
{
|
||||
_logger.LogWarning("Dead torrent config is enabled but no categories are configured for {name}", downloadService.ClientConfig.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ITorrentItemWrapper> candidates = clientDownloads
|
||||
.Where(t => !string.IsNullOrEmpty(t.Hash))
|
||||
.Where(t => config.Categories.Any(cat => cat.Equals(t.Category, StringComparison.OrdinalIgnoreCase)))
|
||||
.Where(t => config.UseTag
|
||||
? !t.Tags.Contains(config.TargetCategory, StringComparer.OrdinalIgnoreCase)
|
||||
: !config.TargetCategory.Equals(t.Category, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count is 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await downloadService.CreateCategoryAsync(config.TargetCategory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create category {category}", config.TargetCategory);
|
||||
}
|
||||
|
||||
foreach (ITorrentItemWrapper torrent in candidates)
|
||||
{
|
||||
ContextProvider.SetDownloadClient(downloadService.ClientConfig);
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
|
||||
if (torrent.SeederCount > 0)
|
||||
{
|
||||
await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, StrikeType.DeadTorrent);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool shouldMove = await _striker.StrikeAndCheckLimit(
|
||||
torrent.Hash,
|
||||
torrent.Name,
|
||||
config.MaxStrikes,
|
||||
StrikeType.DeadTorrent);
|
||||
|
||||
if (!shouldMove)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await downloadService.ChangeTorrentCategoryAsync(torrent, config.TargetCategory, config.UseTag);
|
||||
|
||||
_logger.LogInformation(
|
||||
"dead torrent moved to {target} | tag: {useTag} | {name}",
|
||||
config.TargetCategory,
|
||||
config.UseTag,
|
||||
torrent.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Moves torrents that have been dead (zero seeders) for a configured number of consecutive runs
|
||||
/// to a target category/tag, so seeding rules can act on them.
|
||||
/// </summary>
|
||||
public interface IDeadTorrentService
|
||||
{
|
||||
/// <summary>
|
||||
/// Strikes torrents reporting zero seeders and moves those that reach the configured limit
|
||||
/// to the target category/tag for the given client run.
|
||||
/// </summary>
|
||||
/// <param name="downloadService">Download-client service for the current client.</param>
|
||||
/// <param name="clientDownloads">The client's torrent items to evaluate.</param>
|
||||
Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
|
||||
}
|
||||
@@ -156,6 +156,22 @@ public partial class DelugeService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
SetDownloadClientContext();
|
||||
|
||||
string currentCategory = torrent.Category ?? string.Empty;
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
|
||||
|
||||
torrent.Category = targetCategory;
|
||||
}
|
||||
|
||||
protected async Task CreateLabel(string name)
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
|
||||
@@ -138,6 +138,9 @@ public abstract class DownloadService : IDownloadService
|
||||
/// <inheritdoc/>
|
||||
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CreateCategoryAsync(string name);
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ public interface IDownloadService : IDisposable
|
||||
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
|
||||
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a single torrent to the target category, or adds it as a tag/label when <paramref name="useTag"/> is set.
|
||||
/// </summary>
|
||||
/// <param name="torrent">The torrent to move.</param>
|
||||
/// <param name="targetCategory">The target category/tag.</param>
|
||||
/// <param name="useTag">When true, add a tag/label instead of changing the category (qBittorrent and Transmission).</param>
|
||||
Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a download item.
|
||||
/// </summary>
|
||||
|
||||
@@ -185,6 +185,25 @@ public partial class QBitService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
SetDownloadClientContext();
|
||||
|
||||
string currentCategory = torrent.Category ?? string.Empty;
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, targetCategory, useTag));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag);
|
||||
|
||||
if (!useTag)
|
||||
{
|
||||
torrent.Category = targetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task CreateCategory(string name)
|
||||
{
|
||||
await _client.AddCategoryAsync(name);
|
||||
|
||||
@@ -150,6 +150,22 @@ public partial class RTorrentService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
SetDownloadClientContext();
|
||||
|
||||
string currentCategory = torrent.Category ?? string.Empty;
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
|
||||
|
||||
torrent.Category = targetCategory;
|
||||
}
|
||||
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetLabelAsync(hash, newLabel);
|
||||
|
||||
@@ -168,6 +168,40 @@ public partial class TransmissionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
|
||||
{
|
||||
var transmissionTorrent = (TransmissionItemWrapper)torrent;
|
||||
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
SetDownloadClientContext();
|
||||
|
||||
string currentCategory = torrent.Category ?? string.Empty;
|
||||
|
||||
if (useTag)
|
||||
{
|
||||
string[] newLabels = torrent.Tags
|
||||
.Append(targetCategory)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(transmissionTorrent.Info.Id, newLabels));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, isTag: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
string newLocation = transmissionTorrent.Info.GetNewLocationByAppend(targetCategory);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
|
||||
|
||||
torrent.Category = targetCategory;
|
||||
}
|
||||
|
||||
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
|
||||
@@ -133,6 +133,22 @@ public partial class UTorrentService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
SetDownloadClientContext();
|
||||
|
||||
string currentCategory = torrent.Category ?? string.Empty;
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
|
||||
|
||||
torrent.Category = targetCategory;
|
||||
}
|
||||
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetTorrentLabelAsync(hash, newLabel);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
|
||||
|
||||
public sealed class DeadTorrentConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_EnabledIsFalse()
|
||||
{
|
||||
var config = new DeadTorrentConfig();
|
||||
config.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_TargetCategoryIsSet()
|
||||
{
|
||||
var config = new DeadTorrentConfig();
|
||||
config.TargetCategory.ShouldBe("cleanuparr-dead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenDisabled_DoesNotThrow()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = false,
|
||||
TargetCategory = "",
|
||||
Categories = [],
|
||||
MaxStrikes = 0
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithValidConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
Categories = ["movies", "tv"],
|
||||
MaxStrikes = 3
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithEmptyTargetCategory_ThrowsValidationException()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "",
|
||||
Categories = ["movies"],
|
||||
MaxStrikes = 3
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Dead torrent target category is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithEmptyCategories_ThrowsValidationException()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
Categories = [],
|
||||
MaxStrikes = 3
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("No dead torrent categories configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithTargetCategoryInCategories_ThrowsValidationException()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
Categories = ["movies", "cleanuparr-dead"],
|
||||
MaxStrikes = 3
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("The dead torrent target category should not be present in dead torrent categories");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithEmptyCategoryEntry_ThrowsValidationException()
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
Categories = ["movies", ""],
|
||||
MaxStrikes = 3
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Empty dead torrent category filter found");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public void Validate_WhenEnabled_WithStrikesBelowMinimum_ThrowsValidationException(ushort strikes)
|
||||
{
|
||||
var config = new DeadTorrentConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "cleanuparr-dead",
|
||||
Categories = ["movies"],
|
||||
MaxStrikes = strikes
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Dead torrent max strikes must be at least 3");
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,9 @@ public class DataContext : DbContext
|
||||
public DbSet<RTorrentSeedingRule> RTorrentSeedingRules { get; set; }
|
||||
|
||||
public DbSet<UnlinkedConfig> UnlinkedConfigs { get; set; }
|
||||
|
||||
|
||||
public DbSet<DeadTorrentConfig> DeadTorrentConfigs { get; set; }
|
||||
|
||||
public DbSet<ArrConfig> ArrConfigs { get; set; }
|
||||
|
||||
public DbSet<ArrInstance> ArrInstances { get; set; }
|
||||
@@ -371,6 +373,19 @@ public class DataContext : DbContext
|
||||
entity.HasIndex(u => u.DownloadClientConfigId).IsUnique();
|
||||
});
|
||||
|
||||
// Configure per-client dead torrent config relationship
|
||||
modelBuilder.Entity<DeadTorrentConfig>(entity =>
|
||||
{
|
||||
entity.HasOne(d => d.DownloadClientConfig)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.DownloadClientConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(d => d.DownloadClientConfigId).IsUnique();
|
||||
|
||||
entity.Property(d => d.Categories).HasConversion(jsonListConverter);
|
||||
});
|
||||
|
||||
// Configure per-client orphaned files config relationship
|
||||
modelBuilder.Entity<OrphanedFilesConfig>(entity =>
|
||||
{
|
||||
|
||||
2255
code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.Designer.cs
generated
Normal file
2255
code/backend/Cleanuparr.Persistence/Migrations/Data/20260613142438_AddDeadTorrentConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDeadTorrentConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dead_torrent_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
target_category = table.Column<string>(type: "TEXT", nullable: false),
|
||||
use_tag = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
max_strikes = table.Column<ushort>(type: "INTEGER", nullable: false),
|
||||
categories = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_dead_torrent_configs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_dead_torrent_configs_download_clients_download_client_config_id",
|
||||
column: x => x.download_client_config_id,
|
||||
principalTable: "download_clients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_dead_torrent_configs_download_client_config_id",
|
||||
table: "dead_torrent_configs",
|
||||
column: "download_client_config_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "dead_torrent_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,49 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("blacklist_sync_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Categories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("categories");
|
||||
|
||||
b.Property<Guid>("DownloadClientConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_strikes");
|
||||
|
||||
b.Property<string>("TargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("target_category");
|
||||
|
||||
b.Property<bool>("UseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_tag");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dead_torrent_configs");
|
||||
|
||||
b.HasIndex("DownloadClientConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_dead_torrent_configs_download_client_config_id");
|
||||
|
||||
b.ToTable("dead_torrent_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1887,6 +1930,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadClientConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_config_id");
|
||||
|
||||
b.Navigation("DownloadClientConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
|
||||
public sealed record DeadTorrentConfig : IConfig
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid DownloadClientConfigId { get; set; }
|
||||
|
||||
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
|
||||
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Category/tag a dead torrent is moved to once it reaches <see cref="MaxStrikes"/>.
|
||||
/// </summary>
|
||||
public string TargetCategory { get; set; } = "cleanuparr-dead";
|
||||
|
||||
/// <summary>
|
||||
/// When true, add a tag/label instead of changing the category. Supported by qBittorrent and Transmission.
|
||||
/// </summary>
|
||||
public bool UseTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive runs a torrent must report zero seeders before being moved.
|
||||
/// </summary>
|
||||
public ushort MaxStrikes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source categories to scan for dead torrents. At least one must be specified.
|
||||
/// </summary>
|
||||
public List<string> Categories { get; set; } = [];
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TargetCategory))
|
||||
{
|
||||
throw new ValidationException("Dead torrent target category is required");
|
||||
}
|
||||
|
||||
if (Categories.Count is 0)
|
||||
{
|
||||
throw new ValidationException("No dead torrent categories configured");
|
||||
}
|
||||
|
||||
if (Categories.Contains(TargetCategory, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ValidationException("The dead torrent target category should not be present in dead torrent categories");
|
||||
}
|
||||
|
||||
if (Categories.Any(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
throw new ValidationException("Empty dead torrent category filter found");
|
||||
}
|
||||
|
||||
if (MaxStrikes < 3)
|
||||
{
|
||||
throw new ValidationException("Dead torrent max strikes must be at least 3");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DownloadCleanerConfig,
|
||||
SeedingRule,
|
||||
UnlinkedConfigModel,
|
||||
DeadTorrentConfigModel,
|
||||
OrphanedFilesConfig,
|
||||
} from '@shared/models/download-cleaner-config.model';
|
||||
|
||||
@@ -42,12 +43,17 @@ export class DownloadCleanerApi {
|
||||
}
|
||||
|
||||
// Unlinked config
|
||||
updateUnlinkedConfig(clientId: string, config: Partial<UnlinkedConfigModel>): Observable<void> {
|
||||
updateUnlinkedConfig(clientId: string, config: UnlinkedConfigModel): Observable<void> {
|
||||
return this.http.put<void>(`/api/unlinked-config/${clientId}`, config);
|
||||
}
|
||||
|
||||
// Dead torrent config
|
||||
updateDeadTorrentConfig(clientId: string, config: DeadTorrentConfigModel): Observable<void> {
|
||||
return this.http.put<void>(`/api/dead-torrent-config/${clientId}`, config);
|
||||
}
|
||||
|
||||
// Per-client orphaned files config
|
||||
updateOrphanedFilesConfig(clientId: string, config: Partial<OrphanedFilesConfig>): Observable<OrphanedFilesConfig> {
|
||||
updateOrphanedFilesConfig(clientId: string, config: OrphanedFilesConfig): Observable<OrphanedFilesConfig> {
|
||||
return this.http.put<OrphanedFilesConfig>(`/api/orphaned-files-config/${clientId}`, config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -201,6 +201,48 @@
|
||||
</div>
|
||||
</app-accordion>
|
||||
|
||||
@if (isDeadTorrentCapableClient()) {
|
||||
<app-accordion header="Dead Torrents" subtitle="Triage torrents with no seeders" [(expanded)]="deadTorrentExpanded">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Enabled" [checked]="client.deadTorrentConfig?.enabled ?? false"
|
||||
(checkedChange)="updateDeadTorrentField('enabled', $event)"
|
||||
hint="When enabled, torrents reporting no seeders (or whose tracker is unreachable) for a number of consecutive runs are moved to a target category/tag"
|
||||
helpKey="download-cleaner:deadTorrentEnabled" />
|
||||
@if (client.deadTorrentConfig?.enabled) {
|
||||
<app-input label="Target Category" placeholder="cleanuparr-dead" [value]="client.deadTorrentConfig?.targetCategory ?? ''"
|
||||
(valueChange)="updateDeadTorrentField('targetCategory', $event)"
|
||||
hint="Category/tag dead torrents are moved to. Create a seeding rule for this category to control what happens next."
|
||||
helpKey="download-cleaner:deadTorrentTargetCategory" />
|
||||
@if (isTagFilterableClient()) {
|
||||
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.deadTorrentConfig?.useTag ?? false"
|
||||
(checkedChange)="updateDeadTorrentField('useTag', $event)"
|
||||
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, uses a tag instead of category'"
|
||||
helpKey="download-cleaner:deadTorrentUseTag" />
|
||||
}
|
||||
<app-number-input label="Strikes" [value]="client.deadTorrentConfig?.maxStrikes ?? null" [min]="3" [step]="1"
|
||||
(valueChange)="updateDeadTorrentField('maxStrikes', $event ?? 0)"
|
||||
[error]="deadTorrentStrikesError()"
|
||||
hint="Consecutive runs with no seeders before moving the torrent (minimum 3). Set this high enough to ride out tracker downtime — e.g. with an hourly cleaner, 168 ≈ 1 week. Strikes reset once seeders are found again."
|
||||
helpKey="download-cleaner:deadTorrentStrikes" />
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<app-chip-input label="Categories" placeholder="Add category..."
|
||||
[items]="client.deadTorrentConfig?.categories ?? []"
|
||||
(itemsChange)="updateDeadTorrentField('categories', $event)"
|
||||
hint="Categories to scan for dead torrents"
|
||||
[error]="deadTorrentCategoriesError()"
|
||||
helpKey="download-cleaner:deadTorrentCategories" />
|
||||
}
|
||||
<div class="form-actions">
|
||||
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || !!deadTorrentCategoriesError() || !!deadTorrentStrikesError()" (clicked)="saveDeadTorrentConfig()">
|
||||
{{ deadTorrentSaved() ? 'Saved!' : 'Save' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-accordion>
|
||||
}
|
||||
|
||||
<app-accordion header="Orphaned Files" subtitle="Move files not associated with any active torrent" [(expanded)]="orphanedFilesExpanded">
|
||||
<div class="form-stack">
|
||||
<app-toggle
|
||||
|
||||
@@ -15,8 +15,8 @@ import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import {
|
||||
DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel,
|
||||
OrphanedFilesConfig,
|
||||
createDefaultUnlinkedConfig, createDefaultOrphanedFilesConfig,
|
||||
DeadTorrentConfigModel, OrphanedFilesConfig,
|
||||
createDefaultUnlinkedConfig, createDefaultDeadTorrentConfig, createDefaultOrphanedFilesConfig,
|
||||
} from '@shared/models/download-cleaner-config.model';
|
||||
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
|
||||
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
|
||||
@@ -73,10 +73,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
readonly saved = signal(false);
|
||||
readonly unlinkedSaving = signal(false);
|
||||
readonly unlinkedSaved = signal(false);
|
||||
readonly deadTorrentSaving = signal(false);
|
||||
readonly deadTorrentSaved = signal(false);
|
||||
readonly orphanedFilesSaving = signal(false);
|
||||
readonly orphanedFilesSaved = signal(false);
|
||||
readonly rulesReloading = signal(false);
|
||||
private readonly unlinkedSnapshots = signal<Record<string, string>>({});
|
||||
private readonly deadTorrentSnapshots = signal<Record<string, string>>({});
|
||||
|
||||
// Global settings
|
||||
readonly enabled = signal(false);
|
||||
@@ -125,8 +128,18 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
|| typeName === DownloadClientTypeName.uTorrent;
|
||||
});
|
||||
|
||||
// Dead torrent detection needs a seeder count; rTorrent does not report one.
|
||||
readonly isDeadTorrentCapableClient = computed(() => {
|
||||
const typeName = this.selectedClient()?.downloadClientTypeName;
|
||||
return typeName === DownloadClientTypeName.qBittorrent
|
||||
|| typeName === DownloadClientTypeName.Deluge
|
||||
|| typeName === DownloadClientTypeName.Transmission
|
||||
|| typeName === DownloadClientTypeName.uTorrent;
|
||||
});
|
||||
|
||||
readonly seedingRulesExpanded = signal(false);
|
||||
readonly unlinkedExpanded = signal(false);
|
||||
readonly deadTorrentExpanded = signal(false);
|
||||
readonly orphanedFilesExpanded = signal(false);
|
||||
|
||||
// Seeding rule modal
|
||||
@@ -212,6 +225,28 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly deadTorrentCategoriesError = computed(() => {
|
||||
const client = this.selectedClient();
|
||||
if (!client?.deadTorrentConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
if ((client.deadTorrentConfig.categories ?? []).length === 0) {
|
||||
return 'At least one category is required';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly deadTorrentStrikesError = computed(() => {
|
||||
const client = this.selectedClient();
|
||||
if (!client?.deadTorrentConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
if ((client.deadTorrentConfig.maxStrikes ?? 0) < 3) {
|
||||
return 'Strikes must be at least 3';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly orphanedFilesScanDirsError = computed(() => {
|
||||
const client = this.selectedClient();
|
||||
if (!client?.orphanedFilesConfig?.enabled) {
|
||||
@@ -244,6 +279,16 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
return saved !== JSON.stringify(client.unlinkedConfig);
|
||||
});
|
||||
|
||||
readonly deadTorrentDirty = computed(() => {
|
||||
const client = this.selectedClient();
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const saved = this.deadTorrentSnapshots()[client.downloadClientId]
|
||||
?? JSON.stringify(createDefaultDeadTorrentConfig());
|
||||
return saved !== JSON.stringify(client.deadTorrentConfig);
|
||||
});
|
||||
|
||||
readonly orphanedFilesDirty = computed(() => {
|
||||
const client = this.selectedClient();
|
||||
if (!client) {
|
||||
@@ -292,6 +337,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
...c,
|
||||
seedingRules: c.seedingRules ?? [],
|
||||
unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(),
|
||||
deadTorrentConfig: c.deadTorrentConfig ?? createDefaultDeadTorrentConfig(),
|
||||
orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(),
|
||||
})));
|
||||
|
||||
@@ -300,12 +346,15 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
}
|
||||
|
||||
const unlinkedSnapshots: Record<string, string> = {};
|
||||
const deadTorrentSnapshots: Record<string, string> = {};
|
||||
const orphanedFilesSnapshots: Record<string, string> = {};
|
||||
for (const c of dc.clients ?? []) {
|
||||
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
|
||||
deadTorrentSnapshots[c.downloadClientId] = JSON.stringify(c.deadTorrentConfig ?? createDefaultDeadTorrentConfig());
|
||||
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig());
|
||||
}
|
||||
this.unlinkedSnapshots.set(unlinkedSnapshots);
|
||||
this.deadTorrentSnapshots.set(deadTorrentSnapshots);
|
||||
this.orphanedFilesSnapshots.set(orphanedFilesSnapshots);
|
||||
|
||||
this.loader.stop();
|
||||
@@ -462,7 +511,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
}
|
||||
|
||||
async onClientChange(newClientId: unknown): Promise<void> {
|
||||
if (this.unlinkedDirty() || this.orphanedFilesDirty()) {
|
||||
if (this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty()) {
|
||||
const confirmed = await this.confirm.confirm({
|
||||
title: 'Unsaved Changes',
|
||||
message: 'You have unsaved changes for this client. Discard them?',
|
||||
@@ -472,10 +521,32 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const currentId = this.selectedClientId();
|
||||
if (currentId) {
|
||||
this.restoreClientEditsFromSnapshot(currentId);
|
||||
}
|
||||
}
|
||||
this.selectedClientId.set(newClientId as string | null);
|
||||
}
|
||||
|
||||
/** Reverts the client's unlinked/dead-torrent/orphaned edits back to their saved snapshots. */
|
||||
private restoreClientEditsFromSnapshot(clientId: string): void {
|
||||
this.clientConfigs.update(configs => configs.map(c => {
|
||||
if (c.downloadClientId !== clientId) {
|
||||
return c;
|
||||
}
|
||||
const unlinked = this.unlinkedSnapshots()[clientId];
|
||||
const deadTorrent = this.deadTorrentSnapshots()[clientId];
|
||||
const orphaned = this.orphanedFilesSnapshots()[clientId];
|
||||
return {
|
||||
...c,
|
||||
unlinkedConfig: unlinked ? JSON.parse(unlinked) : c.unlinkedConfig,
|
||||
deadTorrentConfig: deadTorrent ? JSON.parse(deadTorrent) : c.deadTorrentConfig,
|
||||
orphanedFilesConfig: orphaned ? JSON.parse(orphaned) : c.orphanedFilesConfig,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Unlinked config ---
|
||||
|
||||
updateUnlinkedField<K extends keyof UnlinkedConfigModel>(field: K, value: UnlinkedConfigModel[K]): void {
|
||||
@@ -514,6 +585,44 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Dead torrent per-client config ---
|
||||
|
||||
updateDeadTorrentField<K extends keyof DeadTorrentConfigModel>(field: K, value: DeadTorrentConfigModel[K]): void {
|
||||
this.updateSelectedClient(client => ({
|
||||
...client,
|
||||
deadTorrentConfig: {
|
||||
...(client.deadTorrentConfig ?? createDefaultDeadTorrentConfig()),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
saveDeadTorrentConfig(): void {
|
||||
const clientId = this.selectedClientId();
|
||||
const client = this.selectedClient();
|
||||
if (!clientId || !client?.deadTorrentConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deadTorrentSaving.set(true);
|
||||
this.api.updateDeadTorrentConfig(clientId, client.deadTorrentConfig).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Dead torrent config saved');
|
||||
this.deadTorrentSaving.set(false);
|
||||
this.deadTorrentSaved.set(true);
|
||||
setTimeout(() => this.deadTorrentSaved.set(false), 1500);
|
||||
this.deadTorrentSnapshots.update(s => ({
|
||||
...s,
|
||||
[clientId]: JSON.stringify(client.deadTorrentConfig),
|
||||
}));
|
||||
},
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save dead torrent config');
|
||||
this.deadTorrentSaving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Orphaned files per-client config ---
|
||||
|
||||
updateOrphanedFilesField<K extends keyof OrphanedFilesConfig>(field: K, value: OrphanedFilesConfig[K]): void {
|
||||
@@ -615,6 +724,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
});
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.dirty() || this.unlinkedDirty() || this.orphanedFilesDirty();
|
||||
return this.dirty() || this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -438,4 +438,60 @@ Categories to check for unlinked downloads. Only downloads in these categories w
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle>Dead Torrents</SectionTitle>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
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 <strong>Dead Torrents</strong> accordion.
|
||||
</p>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<ConfigSection
|
||||
title="Enable Dead Torrent Handling"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Dead Torrent Target Category"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Dead Torrent Use Tag"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Dead Torrent Strikes"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Dead Torrent Categories"
|
||||
>
|
||||
|
||||
Source categories to scan for dead torrents. Only downloads in these categories are checked. The target category must not be one of these.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
|
||||
280
e2e/tests/dead-torrent-cleanup.spec.ts
Normal file
280
e2e/tests/dead-torrent-cleanup.spec.ts
Normal file
@@ -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<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
interface DriverLike {
|
||||
readonly typeName: string;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
ready(): Promise<void>;
|
||||
clearAllTorrents(): Promise<void>;
|
||||
listTorrents(): Promise<Array<{ hash: string; name: string }>>;
|
||||
}
|
||||
|
||||
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<string>;
|
||||
/** True once the torrent has been moved to the target category / tagged. */
|
||||
isMoved(infoHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<string, string>();
|
||||
const alive = new Map<string, string>();
|
||||
const prepared = new Set<string>();
|
||||
|
||||
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<string, boolean>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
140
e2e/tests/dead-torrent-config-api.spec.ts
Normal file
140
e2e/tests/dead-torrent-config-api.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -266,6 +266,27 @@ export async function updateUnlinkedConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDeadTorrentConfig(accessToken: string, downloadClientId: string): Promise<Response> {
|
||||
return fetch(`${API}/api/dead-torrent-config/${downloadClientId}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDeadTorrentConfig(
|
||||
accessToken: string,
|
||||
downloadClientId: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
await this.ensureLabel(category);
|
||||
const hash = await this.call<string>('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<void> {
|
||||
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<string | undefined> {
|
||||
const result = await this.call<Record<string, { label?: string }>>(
|
||||
'core.get_torrents_status',
|
||||
[{ id: [infoHash] }, ['label']],
|
||||
);
|
||||
return result?.[infoHash]?.label || undefined;
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
// remove_torrent signature: (torrent_id, remove_data: bool)
|
||||
await this.call('core.remove_torrent', [infoHash, false]);
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | undefined> {
|
||||
const t = await this.getTorrent(infoHash);
|
||||
return t?.category;
|
||||
}
|
||||
|
||||
/** Returns the torrent's tags (comma-separated in qBit). */
|
||||
async getTorrentTags(infoHash: string): Promise<string[]> {
|
||||
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<void> {
|
||||
const body = new URLSearchParams({ hashes: infoHash, deleteFiles: 'false' });
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, {
|
||||
|
||||
@@ -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<void> {
|
||||
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<string[]> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
await this.call('torrent-remove', {
|
||||
ids: [infoHash],
|
||||
@@ -91,12 +118,12 @@ export class TransmissionDriver implements TorrentClientDriver {
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
async clearAllTorrents(deleteData = false): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
// `remove` removes the torrent from the client without touching files;
|
||||
// `removedata` / `removedatatorrent` delete data and torrent file.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user