Add paused and queued Deluge states for Download Cleaner (#596)

This commit is contained in:
Flaminel
2026-05-04 14:50:07 +03:00
committed by GitHub
parent 3553fce597
commit 48c36fab8f
10 changed files with 285 additions and 67 deletions

View File

@@ -1,27 +1,31 @@
using Newtonsoft.Json;
using Cleanuparr.Domain.Enums;
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.Deluge.Response;
public sealed record DownloadStatus
{
public string? Hash { get; init; }
public string? State { get; init; }
public DelugeState State { get; init; }
public string? Name { get; init; }
public ulong Eta { get; init; }
[JsonProperty("download_payload_rate")]
public long DownloadSpeed { get; init; }
public bool Private { get; init; }
[JsonProperty("total_size")]
public long Size { get; init; }
[JsonProperty("total_done")]
public long TotalDone { get; init; }
[JsonProperty("is_finished")]
public bool IsFinished { get; init; }
public string? Label { get; set; }

View File

@@ -0,0 +1,20 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Torrent state values reported by Deluge
/// </summary>
[JsonConverter(typeof(DelugeStateConverter))]
public enum DelugeState
{
Unknown = 0,
Allocating,
Checking,
Downloading,
Seeding,
Paused,
Error,
Queued,
Moving,
}

View File

@@ -0,0 +1,26 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Maps Deluge wire state strings to <see cref="DelugeState"/>, falling back to <see cref="DelugeState.Unknown"/> for any value not present in the enum
/// </summary>
public sealed class DelugeStateConverter : JsonConverter<DelugeState>
{
public override DelugeState ReadJson(JsonReader reader, Type objectType, DelugeState existingValue, bool hasExistingValue, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.String || reader.Value is not string raw)
{
return DelugeState.Unknown;
}
return Enum.TryParse<DelugeState>(raw, ignoreCase: true, out var parsed)
? parsed
: DelugeState.Unknown;
}
public override void WriteJson(JsonWriter writer, DelugeState value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
@@ -399,13 +400,11 @@ public class DelugeItemWrapperTests
}
[Theory]
[InlineData("Downloading", true)]
[InlineData("downloading", true)]
[InlineData("DOWNLOADING", true)]
[InlineData("Seeding", false)]
[InlineData("Paused", false)]
[InlineData(null, false)]
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
[InlineData(DelugeState.Downloading, true)]
[InlineData(DelugeState.Seeding, false)]
[InlineData(DelugeState.Paused, false)]
[InlineData(DelugeState.Unknown, false)]
public void IsDownloading_ReturnsCorrectValue(DelugeState state, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus
@@ -424,14 +423,14 @@ public class DelugeItemWrapperTests
}
[Theory]
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
[InlineData(DelugeState.Downloading, 0, 0UL, true)]
[InlineData(DelugeState.Downloading, 1000, 0UL, false)]
[InlineData(DelugeState.Downloading, 0, 100UL, false)]
[InlineData(DelugeState.Downloading, 1000, 100UL, false)]
[InlineData(DelugeState.Seeding, 0, 0UL, false)]
[InlineData(DelugeState.Paused, 0, 0UL, false)]
[InlineData(DelugeState.Unknown, 0, 0UL, false)]
public void IsStalled_ReturnsCorrectValue(DelugeState state, long downloadSpeed, ulong eta, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus

View File

@@ -34,9 +34,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = DelugeState.Downloading, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
@@ -51,29 +51,6 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
}
[Fact]
public async Task IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.Count.ShouldBe(2);
}
[Fact]
public async Task ReturnsEmptyList_WhenNull()
{
@@ -99,8 +76,121 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
new DownloadStatus { Hash = "", Name = "No Hash", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
}
[Fact]
public async Task IncludesPausedFinishedTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Paused finished", State = DelugeState.Paused, IsFinished = true, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
}
[Fact]
public async Task IncludesQueuedFinishedTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Queued finished", State = DelugeState.Queued, IsFinished = true, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
}
[Fact]
public async Task ExcludesPausedNotFinished()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Paused mid-download", State = DelugeState.Paused, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldBeEmpty();
}
[Fact]
public async Task ExcludesQueuedNotFinished()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Queued mid-download", State = DelugeState.Queued, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldBeEmpty();
}
[Fact]
public async Task IncludesSeedingRegardlessOfIsFinished()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Seeding without IsFinished flag", State = DelugeState.Seeding, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper

View File

@@ -51,7 +51,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = true,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -96,7 +96,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -148,7 +148,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -187,7 +187,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -239,7 +239,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -267,7 +267,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Label = category,
@@ -296,7 +296,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>
@@ -333,7 +333,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Seeding",
State = DelugeState.Seeding,
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
@@ -374,7 +374,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
@@ -422,7 +422,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
@@ -464,7 +464,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
State = DelugeState.Downloading,
DownloadSpeed = 0,
Eta = 0,
Private = false,

View File

@@ -0,0 +1,77 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Newtonsoft.Json;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DownloadStatusDeserializationTests
{
private const string BaseDelugePayload = """
{
"hash": "abc123",
"name": "Some Torrent",
"eta": 0,
"private": false,
"total_size": 1024,
"total_done": 1024,
"is_finished": true,
"label": "movies",
"seeding_time": 0,
"ratio": 1.0,
"trackers": [],
"download_payload_rate": 0,
"download_location": "/downloads"
}
""";
[Fact]
public void UnknownStateValue_DeserializesToUnknown_AndPreservesOtherFields()
{
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", "\"hash\": \"abc123\", \"state\": \"FutureNewState\",");
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
result.ShouldNotBeNull();
result.State.ShouldBe(DelugeState.Unknown);
result.Hash.ShouldBe("abc123");
result.IsFinished.ShouldBeTrue();
result.Size.ShouldBe(1024L);
}
[Theory]
[InlineData("Seeding")]
[InlineData("seeding")]
[InlineData("SEEDING")]
public void KnownState_DeserializesCaseInsensitively(string wireValue)
{
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", $"\"hash\": \"abc123\", \"state\": \"{wireValue}\",");
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
result.ShouldNotBeNull();
result.State.ShouldBe(DelugeState.Seeding);
}
[Fact]
public void MissingStateField_DeserializesToUnknown()
{
var result = JsonConvert.DeserializeObject<DownloadStatus>(BaseDelugePayload);
result.ShouldNotBeNull();
result.State.ShouldBe(DelugeState.Unknown);
result.Hash.ShouldBe("abc123");
}
[Fact]
public void NullStateField_DeserializesToUnknown()
{
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", "\"hash\": \"abc123\", \"state\": null,");
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
result.ShouldNotBeNull();
result.State.ShouldBe(DelugeState.Unknown);
}
}

View File

@@ -21,6 +21,7 @@ public sealed class DelugeClient
"eta",
"private",
"total_done",
"is_finished",
"label",
"seeding_time",
"ratio",

View File

@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
@@ -58,9 +59,9 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
public IReadOnlyList<string> Tags => Array.Empty<string>();
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };
public bool IsDownloading() => Info is { State: DelugeState.Downloading };
public bool IsStalled() => Info is { State: DelugeState.Downloading, DownloadSpeed: <= 0, Eta: <= 0 };
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{

View File

@@ -21,7 +21,7 @@ public partial class DelugeService
return downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
.Where(x => x.State is DelugeState.Seeding || x is { IsFinished: true, State: DelugeState.Paused or DelugeState.Queued })
.Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x))
.ToList();
}