Fix some dead torrents not being processed (#778)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-08 14:39:26 +03:00
1 parent bba4e943bc
commit 156dc8bf34
19 files changed
+1337 -12

No files matched your search

@@ -1,3 +1,5 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities;
/// <summary>
@@ -27,6 +29,18 @@ public interface ITorrentItemWrapper
/// </summary>
int? SeederCount { get; }
/// <summary>
/// Whether a tracker vouches for the torrent right now.
/// Qualifies <see cref="SeederCount"/>, which clients keep reporting from the last tracker answer they saw.
/// </summary>
TrackerHealth TrackerHealth { get; }
/// <summary>
/// When the download client added the torrent.
/// Null when the client reports no added time.
/// </summary>
DateTimeOffset? AddedOn { get; }
long Eta { get; }
long SeedingTimeSeconds { get; }
@@ -0,0 +1,27 @@
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// What the download client currently knows about a torrent's trackers.
/// </summary>
public enum TrackerHealth
{
/// <summary>
/// The client cannot report tracker state.
/// </summary>
Unsupported,
/// <summary>
/// At least one tracker is answering, so the seeder count is current.
/// </summary>
Working,
/// <summary>
/// A tracker reports that the torrent is no longer registered.
/// </summary>
Unregistered,
/// <summary>
/// No tracker is answering, and nothing indicates the torrent itself is gone.
/// </summary>
Inconclusive,
}
@@ -471,4 +471,40 @@ public class DelugeItemWrapperTests
// Assert
result.ShouldBe(42);
}
[Fact]
public void TrackerHealth_ReturnsUnsupported()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void AddedOn_ReturnsNull()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.AddedOn;
// Assert
result.ShouldBeNull();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using QBittorrent.Client;
using Shouldly;
@@ -650,4 +651,288 @@ public class QBitItemWrapperTests
// Assert
result.ShouldBeFalse();
}
// TrackerHealth property tests
[Fact]
public void TrackerHealth_WithEmptyTrackers_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers = [];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void TrackerHealth_WithWorkingTracker_ReturnsWorking()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.Working,
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Working);
}
[Fact]
public void TrackerHealth_WithAllTrackersFailingAndUnregisteredMessage_ReturnsUnregistered()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.NotWorking,
Message = "Unregistered torrent",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unregistered);
}
[Fact]
public void TrackerHealth_WithAllTrackersFailingAndOutageMessage_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.NotWorking,
Message = "Connection timed out",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void TrackerHealth_WithOneWorkingTrackerBesideAFailingOne_ReturnsWorking()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.Working,
},
new()
{
Url = "http://dead.example.com/announce",
TrackerStatus = TorrentTrackerStatus.NotWorking,
Message = "Unregistered torrent",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Working);
}
[Fact]
public void TrackerHealth_WithUpdatingTrackerBesideAFailingOne_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.Updating,
},
new()
{
Url = "http://dead.example.com/announce",
TrackerStatus = TorrentTrackerStatus.NotWorking,
Message = "Unregistered torrent",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Theory]
[InlineData(5)]
[InlineData(6)]
public void TrackerHealth_WithFailureStatusBeyondTheKnownEnum_ReturnsUnregistered(int status)
{
// qBittorrent 5.2+ emits statuses 5 and 6, which the client library's enum does not name.
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = (TorrentTrackerStatus)status,
Message = "Unregistered torrent",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
TrackerHealth result = wrapper.TrackerHealth;
result.ShouldBe(TrackerHealth.Unregistered);
}
[Fact]
public void TrackerHealth_WithNullTrackerStatus_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = null,
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void TrackerHealth_WithWorkingDhtPseudoTracker_IgnoresItAndReturnsUnregistered()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = "** [DHT] **",
TrackerStatus = TorrentTrackerStatus.Working,
},
new()
{
Url = "http://tracker.example.com/announce",
TrackerStatus = TorrentTrackerStatus.NotWorking,
Message = "Unregistered torrent",
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unregistered);
}
[Fact]
public void AddedOn_ReturnsCorrectValue()
{
// Arrange
DateTime expectedAddedOn = new(2024, 5, 17, 8, 30, 0, DateTimeKind.Utc);
TorrentInfo torrentInfo = new() { AddedOn = expectedAddedOn };
List<TorrentTracker> trackers = [];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
DateTimeOffset? result = wrapper.AddedOn;
// Assert
result.ShouldBe(new DateTimeOffset(expectedAddedOn));
}
[Fact]
public void AddedOn_WhenClientKindIsUnspecified_IsReadAsUtc()
{
// Arrange
// The client's deserializer hands back the correct UTC wall time with an unspecified kind.
DateTime addedOn = new(2024, 5, 17, 8, 30, 0, DateTimeKind.Unspecified);
TorrentInfo torrentInfo = new() { AddedOn = addedOn };
List<TorrentTracker> trackers = [];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
DateTimeOffset? result = wrapper.AddedOn;
// Assert
result.ShouldBe(new DateTimeOffset(2024, 5, 17, 8, 30, 0, TimeSpan.Zero));
result!.Value.Offset.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void AddedOn_WhenNull_ReturnsNull()
{
// Arrange
TorrentInfo torrentInfo = new() { AddedOn = null };
List<TorrentTracker> trackers = [];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
DateTimeOffset? result = wrapper.AddedOn;
// Assert
result.ShouldBeNull();
}
[Fact]
public void TrackerHealth_WithNotContactedTracker_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new();
List<TorrentTracker> trackers =
[
new()
{
Url = null,
TrackerStatus = TorrentTrackerStatus.NotContacted,
},
];
QBitItemWrapper wrapper = new(torrentInfo, trackers, false);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
}
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Shouldly;
using Xunit;
@@ -594,4 +595,32 @@ public class RTorrentItemWrapperTests
// Assert
result.ShouldBeNull();
}
[Fact]
public void TrackerHealth_ReturnsUnsupported()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void AddedOn_ReturnsNull()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.AddedOn;
// Assert
result.ShouldBeNull();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
@@ -509,4 +510,249 @@ public class TransmissionItemWrapperTests
// Assert
result.ShouldBeFalse();
}
}
// TrackerHealth property tests
[Fact]
public void TrackerHealth_WithNullTrackerStats_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new() { TrackerStats = null };
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void TrackerHealth_WithEmptyTrackerStats_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new() { TrackerStats = Array.Empty<TransmissionTorrentTrackerStats>() };
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void TrackerHealth_WithSuccessfulAnnounce_ReturnsWorking()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new() { HasAnnounced = true, LastAnnounceSucceeded = true },
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Working);
}
[Fact]
public void TrackerHealth_WithAllAnnouncesFailedAndUnregisteredResult_ReturnsUnregistered()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new()
{
HasAnnounced = true,
LastAnnounceSucceeded = false,
LastAnnounceResult = "Unregistered torrent",
},
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unregistered);
}
[Fact]
public void TrackerHealth_WithAllAnnouncesFailedAndOutageResult_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new()
{
HasAnnounced = true,
LastAnnounceSucceeded = false,
LastAnnounceResult = "Connection timed out",
},
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void TrackerHealth_WithAnnounceInFlight_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new()
{
AnnounceState = 3,
HasAnnounced = true,
LastAnnounceSucceeded = false,
LastAnnounceResult = "Unregistered torrent",
},
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void TrackerHealth_WithTrackerThatNeverAnnounced_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new() { HasAnnounced = false, LastAnnounceSucceeded = false },
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void TrackerHealth_WithOnlyBackupTrackers_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new()
{
IsBackup = true,
HasAnnounced = true,
LastAnnounceSucceeded = false,
LastAnnounceResult = "Unregistered torrent",
},
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void AddedOn_ReturnsCorrectValue()
{
// Arrange
TorrentInfo torrentInfo = new() { AddedDate = 1700000000L };
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
DateTimeOffset? result = wrapper.AddedOn;
// Assert
result.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1700000000L));
}
[Fact]
public void TrackerHealth_WhenAnnouncedWithoutADefiniteResult_ReturnsUnsupported()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new()
{
IsBackup = false,
AnnounceState = 1,
HasAnnounced = true,
LastAnnounceSucceeded = null,
},
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void TrackerHealth_WithUnknownAnnounceHistory_ReturnsInconclusive()
{
// Arrange
TorrentInfo torrentInfo = new()
{
TrackerStats =
[
new() { HasAnnounced = null, LastAnnounceSucceeded = null },
],
};
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
TrackerHealth result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void AddedOn_WhenNull_ReturnsNull()
{
// Arrange
TorrentInfo torrentInfo = new() { AddedDate = null };
TransmissionItemWrapper wrapper = new(torrentInfo);
// Act
DateTimeOffset? result = wrapper.AddedOn;
// Assert
result.ShouldBeNull();
}
}
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Shouldly;
using Xunit;
@@ -292,4 +293,46 @@ public class UTorrentItemWrapperTests
// Assert
result.ShouldBe(15);
}
[Fact]
public void TrackerHealth_ReturnsUnsupported()
{
// Arrange
var torrentItem = new UTorrentItem();
var wrapper = new UTorrentItemWrapper(torrentItem, new UTorrentProperties());
// Act
var result = wrapper.TrackerHealth;
// Assert
result.ShouldBe(TrackerHealth.Unsupported);
}
[Fact]
public void AddedOn_ReturnsCorrectValue()
{
// Arrange
var torrentItem = new UTorrentItem { DateAdded = 1700000000 };
var wrapper = new UTorrentItemWrapper(torrentItem, new UTorrentProperties());
// Act
var result = wrapper.AddedOn;
// Assert
result.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1700000000));
}
[Fact]
public void AddedOn_WhenZero_ReturnsNull()
{
// Arrange
var torrentItem = new UTorrentItem { DateAdded = 0 };
var wrapper = new UTorrentItemWrapper(torrentItem, new UTorrentProperties());
// Act
var result = wrapper.AddedOn;
// Assert
result.ShouldBeNull();
}
}
@@ -8,6 +8,7 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Shouldly;
using Xunit;
@@ -20,6 +21,7 @@ public sealed class DeadTorrentServiceTests : IDisposable
private readonly IStriker _striker;
private readonly IDownloadService _downloadService;
private readonly DownloadClientConfig _clientConfig;
private readonly FakeTimeProvider _timeProvider;
private readonly DeadTorrentService _sut;
public DeadTorrentServiceTests()
@@ -27,6 +29,7 @@ public sealed class DeadTorrentServiceTests : IDisposable
_dataContext = TestDataContextFactory.Create(seedData: false);
_striker = Substitute.For<IStriker>();
_downloadService = Substitute.For<IDownloadService>();
_timeProvider = new FakeTimeProvider();
_clientConfig = new DownloadClientConfig
{
@@ -42,7 +45,7 @@ public sealed class DeadTorrentServiceTests : IDisposable
_dataContext.DownloadClients.Add(_clientConfig);
_dataContext.SaveChanges();
_sut = new DeadTorrentService(Substitute.For<ILogger<DeadTorrentService>>(), _dataContext, _striker);
_sut = new DeadTorrentService(Substitute.For<ILogger<DeadTorrentService>>(), _dataContext, _timeProvider, _striker);
}
public void Dispose()
@@ -65,7 +68,13 @@ public sealed class DeadTorrentServiceTests : IDisposable
_dataContext.SaveChanges();
}
private static ITorrentItemWrapper CreateTorrent(string hash, string category, int? seederCount, string[]? tags = null)
private static ITorrentItemWrapper CreateTorrent(
string hash,
string category,
int? seederCount,
string[]? tags = null,
TrackerHealth health = TrackerHealth.Unsupported,
DateTimeOffset? addedOn = null)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
@@ -73,6 +82,8 @@ public sealed class DeadTorrentServiceTests : IDisposable
torrent.Category.Returns(category);
torrent.SeederCount.Returns(seederCount);
torrent.Tags.Returns(tags ?? Array.Empty<string>());
torrent.TrackerHealth.Returns(health);
torrent.AddedOn.Returns(addedOn);
return torrent;
}
@@ -200,4 +211,131 @@ public sealed class DeadTorrentServiceTests : IDisposable
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
await _downloadService.DidNotReceiveWithAnyArgs().ChangeTorrentCategoryAsync(default!, default!, default);
}
[Fact]
public async Task Unregistered_WithPositiveSeederCount_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddDays(-30)),
};
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 Unregistered_AtThreshold_MovesToCategory()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(true);
ITorrentItemWrapper torrent = CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddDays(-30));
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper> { torrent };
await _sut.ProcessAsync(_downloadService, downloads);
await _downloadService.Received(1).ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", false);
}
[Fact]
public async Task Unregistered_WithinGracePeriod_ResetsStrikes()
{
AddConfig();
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddMinutes(-10)),
};
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any<string>(), StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task Unregistered_WithFutureAddedOn_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddHours(5)),
};
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 Unregistered_WithNullAddedOn_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered),
};
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 Inconclusive_WithSeeders_ResetsStrikes()
{
AddConfig();
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Inconclusive, addedOn: _timeProvider.GetUtcNow().AddDays(-30)),
};
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any<string>(), StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task Working_WithSeeders_ResetsStrikes()
{
AddConfig();
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Working, addedOn: _timeProvider.GetUtcNow().AddDays(-30)),
};
await _sut.ProcessAsync(_downloadService, downloads);
await _striker.Received(1).ResetStrikeAsync("hash1", Arg.Any<string>(), StrikeType.DeadTorrent);
await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default);
}
[Fact]
public async Task Unregistered_WithZeroSeeders_Strikes()
{
AddConfig();
_striker.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.DeadTorrent)
.Returns(false);
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper>
{
CreateTorrent("hash1", "movies", 0, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddMinutes(-10)),
};
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);
}
}
@@ -156,6 +156,7 @@ public class IntegrationTestFixture : IDisposable
DeadTorrentService = new DeadTorrentService(
Substitute.For<ILogger<DeadTorrentService>>(),
DataContext,
TimeProvider,
Striker);
OrphanedFilesService = new OrphanedFilesCleanupService(
Substitute.For<ILogger<OrphanedFilesCleanupService>>(),
@@ -0,0 +1,101 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class TrackerMessageClassifierTests
{
[Theory]
[InlineData("Unregistered torrent")]
[InlineData("Torrent not found")]
[InlineData("torrent nicht gefunden")]
[InlineData("não registrado")]
[InlineData("nem található")]
[InlineData("Trumped: Internal")]
public void Classify_UnregisteredMessages_ReturnsUnregistered(string message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Unregistered);
}
[Fact]
public void Classify_UnregisteredReasonFollowedByUrl_MatchesReasonAndIgnoresUrlSlug()
{
TrackerMessageClassifier.Classify("Dupe: https://tracker.example/the-dead-zone-1983")
.ShouldBe(TrackerHealth.Unregistered);
TrackerMessageClassifier.Classify("Announce ok: https://tracker.example/the-dead-zone-1983")
.ShouldBe(TrackerHealth.Inconclusive);
}
[Theory]
[InlineData("Stream truncated")]
[InlineData("truncated")]
[InlineData("520 (Unknown HTTP Error)")]
[InlineData("Tracker is down")]
[InlineData("Connection timed out")]
[InlineData("maintenance")]
public void Classify_TrackerDownMessages_ReturnsInconclusive(string message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void Classify_PasskeyProblemPhrasedAsNotRegistered_ReturnsInconclusive()
{
TrackerMessageClassifier.Classify("Torrent not registered, your passkey is unauthorized")
.ShouldBe(TrackerHealth.Inconclusive);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("<none>")]
public void Classify_NullEmptyOrUnknownMessage_ReturnsInconclusive(string? message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Inconclusive);
}
[Theory]
[InlineData("Announce failed for showdown-in-tokyo")]
[InlineData("download failed")]
[InlineData("deadpool announce error")]
[InlineData("another tracker")]
public void Classify_PatternInsideLongerWord_ReturnsInconclusive(string message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Inconclusive);
}
[Theory]
[InlineData("Dead")]
[InlineData("Nuked: bad encode")]
[InlineData("Uploaded")]
[InlineData("Upgraded")]
[InlineData("Trumped: Internal: https://tracker.example/x")]
[InlineData("Dupe: https://tracker.example/the-dead-zone-1983")]
[InlineData("Season pack: https://tracker.example/show-s01")]
public void Classify_BareReasonCode_ReturnsUnregistered(string message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Unregistered);
}
[Theory]
[InlineData("Please use the other tracker")]
[InlineData("You have not uploaded enough")]
[InlineData("tracker is dead")]
[InlineData("Torrent uploaded by another user, see the other tracker")]
[InlineData("Please seed: uploaded ratio too low")]
public void Classify_ReasonWordOutsideLeadingSegment_ReturnsInconclusive(string message)
{
TrackerMessageClassifier.Classify(message).ShouldBe(TrackerHealth.Inconclusive);
}
[Fact]
public void Classify_SpecificPhraseAfterLeadingSegment_ReturnsUnregistered()
{
TrackerMessageClassifier.Classify("Announce failed: torrent not found")
.ShouldBe(TrackerHealth.Unregistered);
}
}
@@ -13,17 +13,22 @@ namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <inheritdoc cref="IDeadTorrentService" />
public sealed class DeadTorrentService : IDeadTorrentService
{
private static readonly TimeSpan UnregisteredGracePeriod = TimeSpan.FromHours(1);
private readonly ILogger<DeadTorrentService> _logger;
private readonly DataContext _dataContext;
private readonly TimeProvider _timeProvider;
private readonly IStriker _striker;
public DeadTorrentService(
ILogger<DeadTorrentService> logger,
DataContext dataContext,
TimeProvider timeProvider,
IStriker striker)
{
_logger = logger;
_dataContext = dataContext;
_timeProvider = timeProvider;
_striker = striker;
}
@@ -40,7 +45,7 @@ public sealed class DeadTorrentService : IDeadTorrentService
if (config.Categories.Count is 0)
{
_logger.LogWarning("Dead torrent config is enabled but no categories are configured for {name}", downloadService.ClientConfig.Name);
_logger.LogWarning("Dead torrent config is enabled but no categories are configured for {Name}", downloadService.ClientConfig.Name);
return;
}
@@ -52,6 +57,12 @@ public sealed class DeadTorrentService : IDeadTorrentService
: !config.TargetCategory.Equals(t.Category, StringComparison.OrdinalIgnoreCase))
.ToList();
_logger.LogDebug(
"dead torrent scan | {Candidates}/{Total} candidates | categories: {Categories}",
candidates.Count,
clientDownloads.Count,
string.Join(", ", config.Categories));
if (candidates.Count is 0)
{
return;
@@ -63,7 +74,7 @@ public sealed class DeadTorrentService : IDeadTorrentService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category {category}", config.TargetCategory);
_logger.LogError(ex, "Failed to create category {Category}", config.TargetCategory);
}
foreach (ITorrentItemWrapper torrent in candidates)
@@ -72,12 +83,24 @@ public sealed class DeadTorrentService : IDeadTorrentService
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
if (torrent.SeederCount > 0)
bool unregistered = torrent.TrackerHealth is TrackerHealth.Unregistered
&& !WithinGracePeriod(torrent);
if (torrent.SeederCount > 0 && !unregistered)
{
await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, StrikeType.DeadTorrent);
continue;
}
string reason = unregistered ? "tracker reports unregistered" : "no seeders";
_logger.LogDebug(
"dead torrent candidate | {Reason} | seeders: {Seeders} | tracker: {Health} | {Name}",
reason,
torrent.SeederCount,
torrent.TrackerHealth,
torrent.Name);
bool shouldMove = await _striker.StrikeAndCheckLimit(
torrent.Hash,
torrent.Name,
@@ -92,10 +115,24 @@ public sealed class DeadTorrentService : IDeadTorrentService
await downloadService.ChangeTorrentCategoryAsync(torrent, config.TargetCategory, config.UseTag);
_logger.LogInformation(
"dead torrent moved to {target} | tag: {useTag} | {name}",
"dead torrent moved to {Target} | {Reason} | tag: {UseTag} | {Name}",
config.TargetCategory,
reason,
config.UseTag,
torrent.Name);
}
}
private bool WithinGracePeriod(ITorrentItemWrapper torrent)
{
if (torrent.AddedOn is null)
{
return false;
}
TimeSpan age = _timeProvider.GetUtcNow() - torrent.AddedOn.Value;
// A client clock ahead of ours yields a negative age, which is not newly added.
return age >= TimeSpan.Zero && age < UnregisteredGracePeriod;
}
}
@@ -46,6 +46,14 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
/// <inheritdoc/>
public int? SeederCount => Info.TotalSeeds;
/// <inheritdoc/>
/// <remarks>Deluge exposes no per-tracker announce result: <c>tracker_status</c> is one free-form string for the whole torrent, and the status key on <c>trackers</c> is a snapshot frozen when the torrent was added.</remarks>
public TrackerHealth TrackerHealth => TrackerHealth.Unsupported;
/// <inheritdoc/>
/// <remarks>The requested Deluge status fields carry no added time; always returns <see langword="null"/>.</remarks>
public DateTimeOffset? AddedOn => null;
/// <summary>
/// The number of seconds that the download needs to finish.
/// A negative value from Deluge shows an unknown time.
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Infrastructure.Services;
@@ -48,6 +49,59 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
/// <inheritdoc/>
public int? SeederCount => Info.TotalSeeds;
/// <inheritdoc/>
public TrackerHealth TrackerHealth
{
get
{
// qBittorrent lists "** [DHT] **" as a Working tracker, and one leaked pseudo-row would mask a dead tracker.
List<TorrentTracker> realTrackers = _trackers
.Where(tracker => tracker.Url?.Contains("**") is not true)
.ToList();
if (realTrackers.Count == 0)
{
return TrackerHealth.Unsupported;
}
// No status number changes verdict class across versions, so the client version does not matter here:
// 3 (updating) exists only on 4.6 to 5.1.
// 5 and 6 exist only on 5.2+.
// 4 means failed on both.
if (realTrackers.Any(tracker => (int?)tracker.TrackerStatus is 2))
{
return TrackerHealth.Working;
}
if (realTrackers.Any(tracker => (int?)tracker.TrackerStatus is 1 or 3))
{
return TrackerHealth.Inconclusive;
}
List<TorrentTracker> failing = realTrackers
.Where(tracker => (int?)tracker.TrackerStatus is 4 or 5 or 6)
.ToList();
if (failing.Count == 0)
{
return TrackerHealth.Unsupported;
}
if (failing.Any(tracker => TrackerMessageClassifier.Classify(tracker.Message) is TrackerHealth.Unregistered))
{
return TrackerHealth.Unregistered;
}
return TrackerHealth.Inconclusive;
}
}
/// <inheritdoc/>
/// <remarks>qBittorrent sends <c>added_on</c> as a UTC instant that deserializes with an unspecified kind.</remarks>
public DateTimeOffset? AddedOn => Info.AddedOn is { } addedOn
? new DateTimeOffset(addedOn, TimeSpan.Zero)
: null;
public long Eta => Info.EstimatedTime?.TotalSeconds is { } eta ? (long)eta : 0;
public long SeedingTimeSeconds => Info.SeedingTime?.TotalSeconds is { } seedTime ? (long)seedTime : 0;
@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
@@ -52,6 +53,14 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper
/// <remarks>rTorrent does not expose seeder counts; always returns <see langword="null"/>.</remarks>
public int? SeederCount => null;
/// <inheritdoc/>
/// <remarks>Cleanuparr fetches no rTorrent tracker state, and the API rejects this feature for rTorrent; always returns <see cref="TrackerHealth.Unsupported"/>.</remarks>
public TrackerHealth TrackerHealth => TrackerHealth.Unsupported;
/// <inheritdoc/>
/// <remarks>rTorrent does not expose the time a torrent was added; always returns <see langword="null"/>.</remarks>
public DateTimeOffset? AddedOn => null;
public long Eta => CalculateEta();
public long SeedingTimeSeconds => CalculateSeedingTime();
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Services;
using Transmission.API.RPC.Entity;
@@ -59,6 +60,65 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
}
}
/// <inheritdoc/>
public TrackerHealth TrackerHealth
{
get
{
if (Info.TrackerStats is not { Length: > 0 } trackerStats)
{
return TrackerHealth.Unsupported;
}
// Transmission pins backup entries at announce state INACTIVE, so only the live entry of a tier carries usable state.
List<TransmissionTorrentTrackerStats> active = trackerStats
.Where(stats => stats.IsBackup is not true)
.ToList();
if (active.Count == 0)
{
return TrackerHealth.Unsupported;
}
if (active.Any(stats => stats.LastAnnounceSucceeded is true))
{
return TrackerHealth.Working;
}
// Announce state 3 is TR_TRACKER_ACTIVE: an announce is in flight and has no result yet.
if (active.Any(stats => stats.AnnounceState == 3))
{
return TrackerHealth.Inconclusive;
}
if (active.Any(stats => stats.HasAnnounced is not true))
{
return TrackerHealth.Inconclusive;
}
List<TransmissionTorrentTrackerStats> failing = active
.Where(stats => stats.LastAnnounceSucceeded is false)
.ToList();
if (failing.Count == 0)
{
return TrackerHealth.Unsupported;
}
if (failing.Any(stats => TrackerMessageClassifier.Classify(stats.LastAnnounceResult) is TrackerHealth.Unregistered))
{
return TrackerHealth.Unregistered;
}
return TrackerHealth.Inconclusive;
}
}
/// <inheritdoc/>
public DateTimeOffset? AddedOn => Info.AddedDate is { } addedDate
? DateTimeOffset.FromUnixTimeSeconds(addedDate)
: null;
public long Eta => Info.Eta ?? 0;
public long SeedingTimeSeconds => Info.SecondsSeeding ?? 0;
@@ -41,6 +41,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS,
TorrentFields.IS_FINISHED,
TorrentFields.ADDED_DATE,
];
public TransmissionService(
@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Infrastructure.Services;
@@ -47,6 +48,15 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
/// <inheritdoc/>
public int? SeederCount => Info.SeedsInSwarm;
/// <inheritdoc/>
/// <remarks>µTorrent exposes no per-tracker state: its only failure hint is a generic error bit that also covers disk errors.</remarks>
public TrackerHealth TrackerHealth => TrackerHealth.Unsupported;
/// <inheritdoc/>
public DateTimeOffset? AddedOn => Info.DateAdded > 0
? DateTimeOffset.FromUnixTimeSeconds(Info.DateAdded)
: null;
public long Eta => Info.ETA;
public long SeedingTimeSeconds => (long?)Info.SeedingTime?.TotalSeconds ?? 0;
@@ -0,0 +1,209 @@
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Services;
/// <summary>
/// Classifies tracker error messages reported by download clients.
/// Patterns come from qbit_manage and autobrr/qui.
/// </summary>
public static partial class TrackerMessageClassifier
{
[GeneratedRegex(@"https?://\S+", RegexOptions.IgnoreCase)]
private static partial Regex UrlRegex();
private static readonly string[] Inconclusive =
[
"could not parse bencoded data",
"expected value (list, dict, int or string) in bencoded string",
"missing info_hash",
"passkey",
"torrent has been postponed",
"you have reached the client limit for this torrent",
];
private static readonly string[] TrackerDown =
[
"(unknown http error)",
"announce is currently unavailable",
"bad gateway",
"bad request",
"cannot connect",
"connection failed",
"down",
"forbidden",
"gateway timeout",
"host not found",
"internal server error",
"it may be down",
"maintenance",
"no connection",
"no data",
"not implemented",
"not responding",
"not working",
"offline",
"refused",
"service unavailable",
"ssl error",
"stream truncated",
"temporarily disabled",
"timed out",
"timeout",
"tracker is down",
"tracker unavailable",
"truncated",
"unable to process your request",
"unauthorized",
"unreachable",
"unresolvable",
"your request could not be processed, please try again later",
];
private static readonly string[] UnregisteredReasons =
[
"i'm sorry dave, i can't do that",
"infohash not found",
"nem található",
"não registrado",
"not exist",
"not registered",
"torrent banned",
"torrent deleted",
"torrent does not exist",
"torrent existiert nicht",
"torrent has been deleted",
"torrent has been nuked",
"torrent has been rejected",
"torrent introuvable",
"torrent is not authorized for use on this tracker",
"torrent is not found",
"torrent nicht gefunden",
"torrent not found",
"unknown torrent",
"unregistered",
];
private static readonly string[] UnregisteredReasonCodes =
[
"complete season uploaded",
"dead",
"dupe",
"grab internal",
"internal available",
"nuked",
"other",
"pack is available",
"packs are available",
"problem with description",
"problem with file",
"problem with pack",
"repack available",
"retitled",
"season pack",
"season pack out",
"season pack uploaded",
"specifically banned",
"trump",
"trumped",
"upgraded",
"uploaded",
];
/// <summary>
/// Decides whether a tracker message says the torrent is gone or only that the tracker is struggling.
/// </summary>
/// <param name="message">Tracker message reported by the download client</param>
/// <returns><see cref="TrackerHealth.Unregistered"/> or <see cref="TrackerHealth.Inconclusive"/></returns>
public static TrackerHealth Classify(string? message)
{
if (string.IsNullOrWhiteSpace(message))
{
return TrackerHealth.Inconclusive;
}
string normalized = UrlRegex().Replace(message, string.Empty).ToLowerInvariant().Trim();
HashSet<string> tokens = Tokenize(normalized);
// an outage can phrase itself as "not registered", so down wins over unregistered
if (Matches(Inconclusive, normalized, tokens) || Matches(TrackerDown, normalized, tokens))
{
return TrackerHealth.Inconclusive;
}
if (Matches(UnregisteredReasons, normalized, tokens))
{
return TrackerHealth.Unregistered;
}
// a reason code is the whole message or its first ":" segment, so prose like "tracker is dead" is not one
if (UnregisteredReasonCodes.Contains(LeadingSegment(normalized), StringComparer.Ordinal))
{
return TrackerHealth.Unregistered;
}
return TrackerHealth.Inconclusive;
}
private static string LeadingSegment(string normalized)
{
int separator = normalized.IndexOf(':', StringComparison.Ordinal);
return separator < 0 ? normalized : normalized[..separator].Trim();
}
private static bool Matches(string[] patterns, string normalized, HashSet<string> tokens)
{
foreach (string pattern in patterns)
{
if (pattern.Contains(' '))
{
if (normalized.Contains(pattern, StringComparison.Ordinal))
{
return true;
}
continue;
}
if (tokens.Contains(pattern))
{
return true;
}
}
return false;
}
private static HashSet<string> Tokenize(string normalized)
{
HashSet<string> tokens = new(StringComparer.Ordinal);
int start = -1;
for (int i = 0; i < normalized.Length; i++)
{
if (char.IsLetterOrDigit(normalized[i]))
{
if (start < 0)
{
start = i;
}
continue;
}
if (start >= 0)
{
tokens.Add(normalized[start..i]);
start = -1;
}
}
if (start >= 0)
{
tokens.Add(normalized[start..]);
}
return tokens;
}
}
@@ -431,18 +431,31 @@ Categories to check for unlinked downloads. Only downloads in these categories w
<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.
A dead torrent has nobody left to seed it, so it can never reach a ratio or seed-time threshold. Cleanuparr moves such a torrent to a target category (or adds a tag/label) instead of deleting it, and a seeding rule on that category/tag decides what happens next. 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.
Cleanuparr strikes a torrent on a run for either of two reasons:
1. **The client reports no seeders**: a count of `0`, or no count at all. Supported on **qBittorrent**, **Transmission**, **Deluge** and **µTorrent**. Not **rTorrent**, which reports no seeder count.
2. **A tracker reports the torrent as no longer registered**, while the client still shows seeders. Supported on **qBittorrent** and **Transmission** only, the two clients that expose per-tracker state and messages. **Deluge** and **µTorrent** expose no usable per-tracker signal, so they rely on the first reason alone.
A torrent is moved once it has been struck on the configured number of consecutive runs, and **strikes reset to zero as soon as a run finds it alive again**.
</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.
When enabled, Cleanuparr scans the configured categories on every run and strikes torrents that look dead. After the configured number of consecutive strikes, the torrent is moved to the target category (or tagged). Create a seeding rule for that category/tag to control whether and when it is removed.
A client's seeder count comes from memory. qBittorrent keeps the last figure a tracker gave it, then falls back to counting peers it once met and still remembers, so a torrent pulled from a private tracker can report seeders for as long as the client stays running. This is why Cleanuparr asks the tracker for its own verdict before it trusts a positive count.
<Important>
A tracker outage does not count. Cleanuparr separates "the tracker says this torrent is gone" from "the tracker is down, timing out, or rejecting the passkey", and strikes on the first case only. Wording that Cleanuparr does not recognize leaves the torrent untouched, which is the cautious direction. A **Transmission** running in a non-English locale writes its own connection errors in that locale, so those messages also read as unrecognized.
</Important>
A newly added torrent gets one hour of grace before the tracker check applies, because a tracker can report a fresh grab as unregistered before it acknowledges it.
</ConfigSection>
@@ -452,6 +465,10 @@ When enabled, torrents in the configured categories that report no seeders for t
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.
<Note>
The move goes one way. Cleanuparr stops scanning a torrent once it carries the target category or tag, so seeders that return later will not move it back. The seeding rule on the target category decides its fate from there.
</Note>
</ConfigSection>
<ConfigSection
@@ -466,9 +483,9 @@ When enabled, adds a tag/label instead of changing the category, preserving the
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.
The number of consecutive runs a torrent must look dead before it is moved. Minimum is `3`. Strikes reset to zero as soon as a run finds seeders and no tracker calling the torrent gone.
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.
Set this **high enough to ride out tracker downtime**, so a tracker under maintenance does not read as a dead one. Strikes protect you for an outage shorter than the strike count, and the move is one way, so pick a number that spans your tracker's worst maintenance window. Choose it together with the Download Cleaner schedule: on an hourly schedule, `24` is about a day and `168` about a week.
</ConfigSection>