mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-13 05:47:33 -04:00
Fix some dead torrents not being processed (#778)
This commit is contained in:
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,
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+285
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+247
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
+140
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
@@ -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>>(),
|
||||
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -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.
|
||||
|
||||
+54
@@ -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;
|
||||
|
||||
+9
@@ -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();
|
||||
|
||||
+60
@@ -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;
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS,
|
||||
TorrentFields.IS_FINISHED,
|
||||
TorrentFields.ADDED_DATE,
|
||||
];
|
||||
|
||||
public TransmissionService(
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user