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;
}
}