diff --git a/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs index 2df3f055..a8a182b0 100644 --- a/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs @@ -1,3 +1,5 @@ +using Cleanuparr.Domain.Enums; + namespace Cleanuparr.Domain.Entities; /// @@ -27,6 +29,18 @@ public interface ITorrentItemWrapper /// int? SeederCount { get; } + /// + /// Whether a tracker vouches for the torrent right now. + /// Qualifies , which clients keep reporting from the last tracker answer they saw. + /// + TrackerHealth TrackerHealth { get; } + + /// + /// When the download client added the torrent. + /// Null when the client reports no added time. + /// + DateTimeOffset? AddedOn { get; } + long Eta { get; } long SeedingTimeSeconds { get; } diff --git a/code/backend/Cleanuparr.Domain/Enums/TrackerHealth.cs b/code/backend/Cleanuparr.Domain/Enums/TrackerHealth.cs new file mode 100644 index 00000000..9b2eac96 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/TrackerHealth.cs @@ -0,0 +1,27 @@ +namespace Cleanuparr.Domain.Enums; + +/// +/// What the download client currently knows about a torrent's trackers. +/// +public enum TrackerHealth +{ + /// + /// The client cannot report tracker state. + /// + Unsupported, + + /// + /// At least one tracker is answering, so the seeder count is current. + /// + Working, + + /// + /// A tracker reports that the torrent is no longer registered. + /// + Unregistered, + + /// + /// No tracker is answering, and nothing indicates the torrent itself is gone. + /// + Inconclusive, +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs index ce23c6d3..ccb323b9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs @@ -471,4 +471,40 @@ public class DelugeItemWrapperTests // Assert result.ShouldBe(42); } + + [Fact] + public void TrackerHealth_ReturnsUnsupported() + { + // Arrange + var downloadStatus = new DownloadStatus + { + Trackers = new List(), + 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(), + DownloadLocation = "/test/path" + }; + var wrapper = new DelugeItemWrapper(downloadStatus); + + // Act + var result = wrapper.AddedOn; + + // Assert + result.ShouldBeNull(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs index 17fe9c9c..55e09b28 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 trackers = + [ + new() + { + Url = null, + TrackerStatus = TorrentTrackerStatus.NotContacted, + }, + ]; + QBitItemWrapper wrapper = new(torrentInfo, trackers, false); + + // Act + TrackerHealth result = wrapper.TrackerHealth; + + // Assert + result.ShouldBe(TrackerHealth.Inconclusive); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs index b3b9ef98..e1805023 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs @@ -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(); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs index 23c530d1..e69e6e7a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs @@ -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(); } -} \ No newline at end of file + + // 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() }; + 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(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs index 61a6fd22..3dbf223c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs @@ -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(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs index 5a0db928..1d667ec0 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs @@ -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(); _downloadService = Substitute.For(); + _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>(), _dataContext, _striker); + _sut = new DeadTorrentService(Substitute.For>(), _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(); 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()); + 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(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + List downloads = new List + { + 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(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } + + [Fact] + public async Task Unregistered_AtThreshold_MovesToCategory() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(true); + ITorrentItemWrapper torrent = CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered, addedOn: _timeProvider.GetUtcNow().AddDays(-30)); + List downloads = new List { torrent }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _downloadService.Received(1).ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", false); + } + + [Fact] + public async Task Unregistered_WithinGracePeriod_ResetsStrikes() + { + AddConfig(); + List downloads = new List + { + 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(), StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default); + } + + [Fact] + public async Task Unregistered_WithFutureAddedOn_Strikes() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + List downloads = new List + { + 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(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } + + [Fact] + public async Task Unregistered_WithNullAddedOn_Strikes() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + List downloads = new List + { + CreateTorrent("hash1", "movies", 2, health: TrackerHealth.Unregistered), + }; + + await _sut.ProcessAsync(_downloadService, downloads); + + await _striker.Received(1).StrikeAndCheckLimit("hash1", Arg.Any(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } + + [Fact] + public async Task Inconclusive_WithSeeders_ResetsStrikes() + { + AddConfig(); + List downloads = new List + { + 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(), StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default); + } + + [Fact] + public async Task Working_WithSeeders_ResetsStrikes() + { + AddConfig(); + List downloads = new List + { + 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(), StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().StrikeAndCheckLimit(default!, default!, default, default); + } + + [Fact] + public async Task Unregistered_WithZeroSeeders_Strikes() + { + AddConfig(); + _striker.StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.DeadTorrent) + .Returns(false); + List downloads = new List + { + 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(), (ushort)3, StrikeType.DeadTorrent); + await _striker.DidNotReceiveWithAnyArgs().ResetStrikeAsync(default!, default!, default); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs index d34ef755..5e0adbf9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs @@ -156,6 +156,7 @@ public class IntegrationTestFixture : IDisposable DeadTorrentService = new DeadTorrentService( Substitute.For>(), DataContext, + TimeProvider, Striker); OrphanedFilesService = new OrphanedFilesCleanupService( Substitute.For>(), diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/TrackerMessageClassifierTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/TrackerMessageClassifierTests.cs new file mode 100644 index 00000000..25080385 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/TrackerMessageClassifierTests.cs @@ -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("")] + 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); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs index 535155d1..fb3b97cf 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs @@ -13,17 +13,22 @@ namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; /// public sealed class DeadTorrentService : IDeadTorrentService { + private static readonly TimeSpan UnregisteredGracePeriod = TimeSpan.FromHours(1); + private readonly ILogger _logger; private readonly DataContext _dataContext; + private readonly TimeProvider _timeProvider; private readonly IStriker _striker; public DeadTorrentService( ILogger 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; + } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs index 17e0c6ef..76e0c2e6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs @@ -46,6 +46,14 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper /// public int? SeederCount => Info.TotalSeeds; + /// + /// Deluge exposes no per-tracker announce result: tracker_status is one free-form string for the whole torrent, and the status key on trackers is a snapshot frozen when the torrent was added. + public TrackerHealth TrackerHealth => TrackerHealth.Unsupported; + + /// + /// The requested Deluge status fields carry no added time; always returns . + public DateTimeOffset? AddedOn => null; + /// /// The number of seconds that the download needs to finish. /// A negative value from Deluge shows an unknown time. diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs index 95be24b3..84eb7865 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs @@ -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 /// public int? SeederCount => Info.TotalSeeds; + /// + public TrackerHealth TrackerHealth + { + get + { + // qBittorrent lists "** [DHT] **" as a Working tracker, and one leaked pseudo-row would mask a dead tracker. + List 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 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; + } + } + + /// + /// qBittorrent sends added_on as a UTC instant that deserializes with an unspecified kind. + 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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs index 5fd2e30d..c5c29e9e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs @@ -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 /// rTorrent does not expose seeder counts; always returns . public int? SeederCount => null; + /// + /// Cleanuparr fetches no rTorrent tracker state, and the API rejects this feature for rTorrent; always returns . + public TrackerHealth TrackerHealth => TrackerHealth.Unsupported; + + /// + /// rTorrent does not expose the time a torrent was added; always returns . + public DateTimeOffset? AddedOn => null; + public long Eta => CalculateEta(); public long SeedingTimeSeconds => CalculateSeedingTime(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs index fe8376c4..647d2ad7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs @@ -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 } } + /// + 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 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 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; + } + } + + /// + public DateTimeOffset? AddedOn => Info.AddedDate is { } addedDate + ? DateTimeOffset.FromUnixTimeSeconds(addedDate) + : null; + public long Eta => Info.Eta ?? 0; public long SeedingTimeSeconds => Info.SecondsSeeding ?? 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index 44427790..b9b38392 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -41,6 +41,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService TorrentFields.TOTAL_SIZE, TorrentFields.LABELS, TorrentFields.IS_FINISHED, + TorrentFields.ADDED_DATE, ]; public TransmissionService( diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs index 09193c35..7d1613fd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs @@ -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 /// public int? SeederCount => Info.SeedsInSwarm; + /// + /// µTorrent exposes no per-tracker state: its only failure hint is a generic error bit that also covers disk errors. + public TrackerHealth TrackerHealth => TrackerHealth.Unsupported; + + /// + public DateTimeOffset? AddedOn => Info.DateAdded > 0 + ? DateTimeOffset.FromUnixTimeSeconds(Info.DateAdded) + : null; + public long Eta => Info.ETA; public long SeedingTimeSeconds => (long?)Info.SeedingTime?.TotalSeconds ?? 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Services/TrackerMessageClassifier.cs b/code/backend/Cleanuparr.Infrastructure/Services/TrackerMessageClassifier.cs new file mode 100644 index 00000000..280e7d22 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Services/TrackerMessageClassifier.cs @@ -0,0 +1,209 @@ +using System.Text.RegularExpressions; +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Services; + +/// +/// Classifies tracker error messages reported by download clients. +/// Patterns come from qbit_manage and autobrr/qui. +/// +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", + ]; + + /// + /// Decides whether a tracker message says the torrent is gone or only that the tracker is struggling. + /// + /// Tracker message reported by the download client + /// or + public static TrackerHealth Classify(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return TrackerHealth.Inconclusive; + } + + string normalized = UrlRegex().Replace(message, string.Empty).ToLowerInvariant().Trim(); + HashSet 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 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 Tokenize(string normalized) + { + HashSet 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; + } +} diff --git a/docs/docs/configuration/download-cleaner/index.mdx b/docs/docs/configuration/download-cleaner/index.mdx index 11f791f5..691f9787 100644 --- a/docs/docs/configuration/download-cleaner/index.mdx +++ b/docs/docs/configuration/download-cleaner/index.mdx @@ -431,18 +431,31 @@ Categories to check for unlinked downloads. Only downloads in these categories w Dead Torrents

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

-A torrent is considered dead on a run when the client reports **no seeders** — a count of `0`, an unknown count (e.g. qBittorrent's `-1`), or no count at all because the tracker is unreachable. It is only moved after it has been dead for the configured number of consecutive runs (the strike count), and **strikes reset to zero as soon as the client reports seeders again**. Supported for **qBittorrent**, **Transmission**, **Deluge**, and **µTorrent** — not **rTorrent**, which does not report a seeder count. +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**. -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. + + +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. + + +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. @@ -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. + +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. + + -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.