diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs
index ca3fa8f1..cbdd89db 100644
--- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs
+++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs
@@ -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; }
diff --git a/code/backend/Cleanuparr.Domain/Enums/DelugeState.cs b/code/backend/Cleanuparr.Domain/Enums/DelugeState.cs
new file mode 100644
index 00000000..00baf481
--- /dev/null
+++ b/code/backend/Cleanuparr.Domain/Enums/DelugeState.cs
@@ -0,0 +1,20 @@
+using Newtonsoft.Json;
+
+namespace Cleanuparr.Domain.Enums;
+
+///
+/// Torrent state values reported by Deluge
+///
+[JsonConverter(typeof(DelugeStateConverter))]
+public enum DelugeState
+{
+ Unknown = 0,
+ Allocating,
+ Checking,
+ Downloading,
+ Seeding,
+ Paused,
+ Error,
+ Queued,
+ Moving,
+}
diff --git a/code/backend/Cleanuparr.Domain/Enums/DelugeStateConverter.cs b/code/backend/Cleanuparr.Domain/Enums/DelugeStateConverter.cs
new file mode 100644
index 00000000..2bb1fd52
--- /dev/null
+++ b/code/backend/Cleanuparr.Domain/Enums/DelugeStateConverter.cs
@@ -0,0 +1,26 @@
+using Newtonsoft.Json;
+
+namespace Cleanuparr.Domain.Enums;
+
+///
+/// Maps Deluge wire state strings to , falling back to for any value not present in the enum
+///
+public sealed class DelugeStateConverter : JsonConverter
+{
+ 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(raw, ignoreCase: true, out var parsed)
+ ? parsed
+ : DelugeState.Unknown;
+ }
+
+ public override void WriteJson(JsonWriter writer, DelugeState value, JsonSerializer serializer)
+ {
+ writer.WriteValue(value.ToString());
+ }
+}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs
index e5c75502..acf4372a 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs
@@ -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
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs
index dc77d722..33da6c9f 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs
@@ -34,9 +34,9 @@ public class DelugeServiceDCTests : IClassFixture
var downloads = new List
{
- new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
- new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
- new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }
+ new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
+ new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = DelugeState.Downloading, Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
+ new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
@@ -51,29 +51,6 @@ public class DelugeServiceDCTests : IClassFixture
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
}
- [Fact]
- public async Task IsCaseInsensitive()
- {
- // Arrange
- var sut = _fixture.CreateSut();
-
- var downloads = new List
- {
- new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
- new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List(), 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
var downloads = new List
{
- new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
- new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }
+ new DownloadStatus { Hash = "", Name = "No Hash", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" },
+ new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = DelugeState.Seeding, Private = false, Trackers = new List(), 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
+ {
+ new DownloadStatus { Hash = "hash1", Name = "Paused finished", State = DelugeState.Paused, IsFinished = true, Private = false, Trackers = new List(), 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
+ {
+ new DownloadStatus { Hash = "hash1", Name = "Queued finished", State = DelugeState.Queued, IsFinished = true, Private = false, Trackers = new List(), 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
+ {
+ new DownloadStatus { Hash = "hash1", Name = "Paused mid-download", State = DelugeState.Paused, IsFinished = false, Private = false, Trackers = new List(), 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
+ {
+ new DownloadStatus { Hash = "hash1", Name = "Queued mid-download", State = DelugeState.Queued, IsFinished = false, Private = false, Trackers = new List(), 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
+ {
+ new DownloadStatus { Hash = "hash1", Name = "Seeding without IsFinished flag", State = DelugeState.Seeding, IsFinished = false, Private = false, Trackers = new List(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs
index 72442b57..b29c7d8d 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs
@@ -51,7 +51,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = true,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -96,7 +96,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -148,7 +148,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -187,7 +187,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -239,7 +239,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -267,7 +267,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Label = category,
@@ -296,7 +296,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List
@@ -333,7 +333,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Seeding",
+ State = DelugeState.Seeding,
Private = false,
DownloadSpeed = 0,
Trackers = new List(),
@@ -374,7 +374,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 0,
Trackers = new List(),
@@ -422,7 +422,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
Private = false,
DownloadSpeed = 1000,
Trackers = new List(),
@@ -464,7 +464,7 @@ public class DelugeServiceTests : IClassFixture
{
Hash = hash,
Name = "Test Torrent",
- State = "Downloading",
+ State = DelugeState.Downloading,
DownloadSpeed = 0,
Eta = 0,
Private = false,
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadStatusDeserializationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadStatusDeserializationTests.cs
new file mode 100644
index 00000000..0ff2a0cb
--- /dev/null
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadStatusDeserializationTests.cs
@@ -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(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(json);
+
+ result.ShouldNotBeNull();
+ result.State.ShouldBe(DelugeState.Seeding);
+ }
+
+ [Fact]
+ public void MissingStateField_DeserializesToUnknown()
+ {
+ var result = JsonConvert.DeserializeObject(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(json);
+
+ result.ShouldNotBeNull();
+ result.State.ShouldBe(DelugeState.Unknown);
+ }
+}
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClient.cs
index de79e87b..f72116a6 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClient.cs
@@ -21,6 +21,7 @@ public sealed class DelugeClient
"eta",
"private",
"total_done",
+ "is_finished",
"label",
"seeding_time",
"ratio",
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs
index ecebf8c8..8a970ce9 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs
@@ -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 Tags => Array.Empty();
- 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 ignoredDownloads)
{
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs
index de4db402..cb8f8c32 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs
@@ -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();
}