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