diff --git a/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs b/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs index 17d52e6c..1d48ce49 100644 --- a/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs +++ b/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Cleanuparr.Infrastructure.Json; namespace Cleanuparr.Api.Json; @@ -23,31 +24,7 @@ public static class CleanuparrJsonConfiguration public static void ConfigureApiInbound(JsonSerializerOptions options) { ConfigureApi(options); - options.TypeInfoResolver = options.TypeInfoResolver!.WithAddedModifier(IgnoreNullForNonNullable); - } - - private static void IgnoreNullForNonNullable(JsonTypeInfo typeInfo) - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - { - return; - } - - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (property.Set is null || property.IsSetNullable) - { - continue; - } - - Action originalSet = property.Set; - property.Set = (obj, value) => - { - if (value is not null) - { - originalSet(obj, value); - } - }; - } + options.TypeInfoResolver = options.TypeInfoResolver! + .WithAddedModifier(CleanuparrJsonOptions.IgnoreNullForNonNullable); } } diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs index 073c9877..57bf4668 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs @@ -1,8 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; +/// +/// An image of a series or a movie in Sonarr, Radarr or Whisparr. +/// public record Image { - public required string CoverType { get; init; } - - public required Uri RemoteUrl { get; init; } -} \ No newline at end of file + /// + /// The type of the image, for example "poster" or "screenshot". + /// + public string CoverType { get; init; } = string.Empty; + + /// + /// The address of the image on the server of the metadata provider. + /// + public Uri? RemoteUrl { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs index f8f9938c..130d4992 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs @@ -1,8 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; +/// +/// An image of an album in Lidarr. +/// public record LidarrImage { - public required string CoverType { get; init; } - - public required Uri Url { get; init; } -} \ No newline at end of file + /// + /// The type of the image, for example "cover". + /// + public string CoverType { get; init; } = string.Empty; + + /// + /// The address of the image. + /// + public Uri? Url { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs index e2f52a90..16863d6d 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs @@ -1,7 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; +/// +/// One page of the queue of an *arr application. +/// public record QueueListResponse { - public required int TotalRecords { get; init; } - public required IReadOnlyList Records { get; init; } -} \ No newline at end of file + /// + /// The number of items in the queue, on this page and on the other pages. + /// + public int TotalRecords { get; init; } + + /// + /// The items on this page. + /// + public IReadOnlyList Records { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs index 287dedf6..c5d6c5c2 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs @@ -1,42 +1,126 @@ namespace Cleanuparr.Domain.Entities.Arr.Queue; +/// +/// One item in the queue of an *arr application. +/// The *arr applications share this type, and each one sets only its own fields. +/// public sealed record QueueRecord { // Sonarr and Whisparr v2 + /// + /// The ID of the series. + /// public long SeriesId { get; init; } + + /// + /// The ID of the episode. + /// public long EpisodeId { get; init; } + + /// + /// The number of the season. + /// public long SeasonNumber { get; init; } - + + /// + /// The data about the series. + /// public QueueSeries? Series { get; init; } - + // Radarr and Whisparr v3 + /// + /// The ID of the movie. + /// public long MovieId { get; init; } - + + /// + /// The data about the movie. + /// public QueueMovie? Movie { get; init; } - + // Lidarr + /// + /// The ID of the artist. + /// public long ArtistId { get; init; } - + + /// + /// The ID of the album. + /// public long AlbumId { get; init; } - + + /// + /// The data about the album. + /// public QueueAlbum? Album { get; init; } - + // Readarr + /// + /// The ID of the author. + /// public long AuthorId { get; init; } - + + /// + /// The ID of the book. + /// public long BookId { get; init; } - + + /// + /// The data about the book. + /// public QueueBook? Book { get; init; } - + // common - public required string Title { get; init; } - public string Status { get; init; } - public string TrackedDownloadStatus { get; init; } - public string TrackedDownloadState { get; init; } + /// + /// The name of the release. + /// + public string Title { get; init; } = string.Empty; + + /// + /// The status of the queue item, for example "downloading" or "delay". + /// + public string Status { get; init; } = string.Empty; + + /// + /// The status of the tracked download, for example "warning". + /// A pending release does not have a tracked download, and the value is then empty. + /// + public string TrackedDownloadStatus { get; init; } = string.Empty; + + /// + /// The state of the tracked download, for example "importBlocked". + /// A pending release does not have a tracked download, and the value is then empty. + /// + public string TrackedDownloadState { get; init; } = string.Empty; + + /// + /// The messages that tell more about the status of the queue item. + /// public List? StatusMessages { get; init; } - public required string DownloadId { get; init; } + + /// + /// The hash that the download client uses for the item. + /// A pending release is not in a download client, and the value is then empty. + /// + public string DownloadId { get; init; } = string.Empty; + + /// + /// The name of the download client that has the item. + /// public string? DownloadClient { get; init; } - public required string Protocol { get; init; } - public required long Id { get; init; } + + /// + /// The protocol of the release, for example "torrent" or "usenet". + /// + public string Protocol { get; init; } = string.Empty; + + /// + /// The ID of the queue item. + /// + public long Id { get; init; } + + /// + /// The number of bytes that the download client must still get. + /// public long SizeLeft { get; init; } -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs index bc15d0f9..102481bc 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs @@ -1,8 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; +/// +/// An image of a book in Readarr. +/// public sealed record ReadarrImage { - public required string CoverType { get; init; } - - public required Uri Url { get; init; } -} \ No newline at end of file + /// + /// The type of the image, for example "cover". + /// + public string CoverType { get; init; } = string.Empty; + + /// + /// The address of the image. + /// + public Uri? Url { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs index 43c3baae..03fa9494 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs @@ -1,8 +1,17 @@ namespace Cleanuparr.Domain.Entities.Arr; +/// +/// A tag of an *arr application. +/// public sealed record Tag { - public required long Id { get; init; } - - public required string Label { get; init; } -} \ No newline at end of file + /// + /// The ID of the tag. + /// + public long Id { get; init; } + + /// + /// The name of the tag. + /// + public string Label { get; init; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeContents.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeContents.cs index 29e62c1c..c04a962d 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeContents.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeContents.cs @@ -2,11 +2,21 @@ namespace Cleanuparr.Domain.Entities.Deluge.Response; +/// +/// The root of the file tree of a torrent in Deluge. +/// public sealed record DelugeContents { + /// + /// The child nodes of the root. + /// [JsonPropertyName("contents")] public Dictionary? Contents { get; set; } + /// + /// The type of the root. + /// The value is always "dir". + /// [JsonPropertyName("type")] - public string Type { get; set; } // Always "dir" for the root + public string Type { get; set; } = string.Empty; } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeError.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeError.cs index 3cae6af7..30fee95f 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeError.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeError.cs @@ -2,11 +2,20 @@ namespace Cleanuparr.Domain.Entities.Deluge.Response; +/// +/// The error of a response from Deluge. +/// public sealed record DelugeError { + /// + /// The text of the error. + /// [JsonPropertyName("message")] - public String Message { get; set; } + public string Message { get; set; } = string.Empty; + /// + /// The code of the error. + /// [JsonPropertyName("code")] public int Code { get; set; } } diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeFileOrDirectory.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeFileOrDirectory.cs index 76effbde..5339b0d7 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeFileOrDirectory.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DelugeFileOrDirectory.cs @@ -1,33 +1,68 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Cleanuparr.Domain.Entities.Deluge.Response; +/// +/// One node in the file tree of a torrent in Deluge. +/// A node is a file or a directory, and the value of tells which one it is. +/// Deluge sends the file fields only on a file node. +/// public class DelugeFileOrDirectory { + /// + /// The type of the node. + /// The value is "file" or "dir". + /// [JsonPropertyName("type")] - public string Type { get; set; } // "file" or "dir" + public string Type { get; set; } = string.Empty; + /// + /// The child nodes of a directory node. + /// [JsonPropertyName("contents")] - public Dictionary? Contents { get; set; } // Recursive property for directories + public Dictionary? Contents { get; set; } + /// + /// The position of the file in the list of files of the torrent. + /// Deluge sends this field only on a file node, and the value is 0 on a directory node. + /// [JsonPropertyName("index")] - public required int Index { get; set; } + public int Index { get; set; } + /// + /// The path of the node in the torrent. + /// [JsonPropertyName("path")] - public string Path { get; set; } + public string Path { get; set; } = string.Empty; + /// + /// The size of the node in bytes. + /// [JsonPropertyName("size")] public int? Size { get; set; } + /// + /// The offset of the file in the data of the torrent. + /// [JsonPropertyName("offset")] public int? Offset { get; set; } + /// + /// The part of the node that Deluge has, as a value from 0 to 100. + /// [JsonPropertyName("progress")] public double? Progress { get; set; } + /// + /// The download priority of the node. + /// A value of 0 tells Deluge to skip the file. + /// [JsonPropertyName("priority")] - public required int Priority { get; set; } + public int Priority { get; set; } + /// + /// The progress of each file below a directory node. + /// [JsonPropertyName("progresses")] - public List Progresses { get; set; } -} \ No newline at end of file + public List Progresses { get; set; } = []; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs index 10f608b3..16a7069e 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs @@ -1,49 +1,104 @@ -using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Enums; using System.Text.Json.Serialization; namespace Cleanuparr.Domain.Entities.Deluge.Response; +/// +/// The status of a torrent in Deluge. +/// Deluge sends only the fields that the request asks for. +/// public sealed record DownloadStatus { + /// + /// The hash of the torrent. + /// public string? Hash { get; init; } + /// + /// The state of the torrent, for example . + /// public DelugeState State { get; init; } + /// + /// The name of the torrent. + /// public string? Name { get; init; } + /// + /// The number of seconds that the download needs to finish. + /// public ulong Eta { get; init; } + /// + /// The download speed in bytes each second. + /// [JsonPropertyName("download_payload_rate")] public long DownloadSpeed { get; init; } + /// + /// True if the tracker of the torrent is private. + /// public bool Private { get; init; } + /// + /// The size of the torrent in bytes. + /// [JsonPropertyName("total_size")] public long Size { get; init; } + /// + /// The number of bytes that Deluge has. + /// [JsonPropertyName("total_done")] public long TotalDone { get; init; } + /// + /// True if the download is complete. + /// [JsonPropertyName("is_finished")] public bool IsFinished { get; init; } + /// + /// The label of the torrent. + /// public string? Label { get; set; } + /// + /// The number of seconds that the torrent has been in the seed state. + /// [JsonPropertyName("seeding_time")] public long SeedingTime { get; init; } + /// + /// The number of bytes that Deluge sent, divided by the number of bytes that Deluge got. + /// public float Ratio { get; init; } + /// + /// The number of seeds that the tracker reports. + /// [JsonPropertyName("total_seeds")] public int TotalSeeds { get; init; } - public required IReadOnlyList Trackers { get; init; } + /// + /// The trackers of the torrent. + /// + public IReadOnlyList Trackers { get; init; } = []; + /// + /// The directory that holds the data of the torrent. + /// [JsonPropertyName("download_location")] - public required string DownloadLocation { get; init; } + public string DownloadLocation { get; init; } = string.Empty; } +/// +/// A tracker of a torrent in Deluge. +/// public sealed record Tracker { - public required string Url { get; init; } + /// + /// The address of the tracker. + /// + public string Url { get; init; } = string.Empty; } diff --git a/code/backend/Cleanuparr.Domain/Entities/Radarr/Movie.cs b/code/backend/Cleanuparr.Domain/Entities/Radarr/Movie.cs index 059ab0c3..81a43e80 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Radarr/Movie.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Radarr/Movie.cs @@ -1,8 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Radarr; +namespace Cleanuparr.Domain.Entities.Radarr; +/// +/// A movie in Radarr or Whisparr v3. +/// public sealed record Movie { - public required long Id { get; init; } - - public required string Title { get; init; } -} \ No newline at end of file + /// + /// The ID of the movie. + /// + public long Id { get; init; } + + /// + /// The name of the movie. + /// + public string Title { get; init; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs b/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs index 0f9f25da..540bad1e 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs @@ -1,12 +1,27 @@ namespace Cleanuparr.Domain.Entities.Readarr; +/// +/// A book in Readarr. +/// public sealed record Book { - public required long Id { get; init; } - - public required string Title { get; init; } - + /// + /// The ID of the book. + /// + public long Id { get; init; } + + /// + /// The name of the book. + /// + public string Title { get; init; } = string.Empty; + + /// + /// The ID of the author of the book. + /// public long AuthorId { get; set; } - + + /// + /// The data about the author of the book. + /// public Author Author { get; set; } = new(); -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sonarr/Series.cs b/code/backend/Cleanuparr.Domain/Entities/Sonarr/Series.cs index b4198cfe..4bed8a02 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Sonarr/Series.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Sonarr/Series.cs @@ -1,8 +1,17 @@ -namespace Cleanuparr.Domain.Entities.Sonarr; +namespace Cleanuparr.Domain.Entities.Sonarr; +/// +/// A series in Sonarr or Whisparr v2. +/// public sealed record Series { - public required long Id { get; init; } - - public required string Title { get; init; } -} \ No newline at end of file + /// + /// The ID of the series. + /// + public long Id { get; init; } + + /// + /// The name of the series. + /// + public string Title { get; init; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/Deluge/DelugeClientSerializationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/Deluge/DelugeClientSerializationTests.cs index 6b391f10..40a5add0 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/Deluge/DelugeClientSerializationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/Deluge/DelugeClientSerializationTests.cs @@ -109,4 +109,47 @@ public class DelugeClientSerializationTests status.State.ShouldBe(DelugeState.Seeding); status.IsFinished.ShouldBeTrue(); } + + [Fact] + public async Task GetTorrentFiles_WithNestedDirectory_DeserializesWithoutIndex() + { + const string response = """ + { + "id": 1, + "result": { + "type": "dir", + "contents": { + "Some.Release": { + "type": "dir", + "priority": 1, + "progress": 1.0, + "progresses": [1.0], + "size": 200, + "path": "Some.Release", + "contents": { + "video.mkv": { + "type": "file", + "index": 0, + "offset": 0, + "path": "Some.Release/video.mkv", + "priority": 1, + "progress": 1.0, + "size": 200 + } + } + } + } + }, + "error": null + } + """; + (DelugeClient client, _) = CreateClient(response); + + DelugeContents? contents = await client.GetTorrentFiles("abc"); + + contents.ShouldNotBeNull(); + DelugeFileOrDirectory directory = contents.Contents!["Some.Release"]; + directory.Type.ShouldBe("dir"); + directory.Contents!["video.mkv"].Index.ShouldBe(0); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 680d9b57..5dc196d9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -359,6 +359,67 @@ public class SeekerTests : IDisposable instanceConfig.LastProcessedAt.ShouldNotBeNull(); } + [Fact] + public async Task ExecuteAsync_ActiveDownloadLimit_IgnoresRecordsWithoutDownloadId() + { + // Arrange — pending releases have no download id and are not in a download client + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + ActiveDownloadLimit = 1 + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + QueueRecord[] pendingReleases = + [ + new() { Id = 1, Title = "Pending 1", Protocol = "torrent", SizeLeft = 1000, MovieId = 10, Status = "delay" }, + new() { Id = 2, Title = "Pending 2", Protocol = "torrent", SizeLeft = 2000, MovieId = 20, Status = "delay" } + ]; + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(ci => ci.ArgAt, Task>>(2)(pendingReleases)); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — the limit of 1 must not be tripped by records that have no download id + await mockArrClient.Received(1) + .SearchItemAsync(radarrInstance, Arg.Any()); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs.FirstAsync(); + instanceConfig.LastProcessedAt.ShouldNotBeNull(); + } + [Fact] public async Task ExecuteAsync_Radarr_ExcludesMoviesAlreadyInQueue() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Json/ExternalApiReadTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Json/ExternalApiReadTests.cs new file mode 100644 index 00000000..ba6e4e00 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Json/ExternalApiReadTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Infrastructure.Json; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Json; + +public class ExternalApiReadTests +{ + [Fact] + public void QueueRecord_WithoutDownloadId_Deserializes() + { + const string payload = """ + { + "totalRecords": 1, + "records": [ + { + "id": 42, + "seriesId": 7, + "title": "Some Release", + "status": "delay", + "protocol": "torrent" + } + ] + } + """; + + QueueListResponse result = JsonSerializer.Deserialize(payload, CleanuparrJsonOptions.ExternalApiRead)!; + + result.TotalRecords.ShouldBe(1); + result.Records.Count.ShouldBe(1); + result.Records[0].Id.ShouldBe(42); + result.Records[0].DownloadId.ShouldBeEmpty(); + } + + [Fact] + public void QueueRecord_WithExplicitNulls_FallsBackToDefaults() + { + const string payload = """ + { + "totalRecords": 1, + "records": [{ "id": 1, "title": "T", "downloadId": null, "protocol": null, "trackedDownloadState": null }] + } + """; + + QueueListResponse result = JsonSerializer.Deserialize(payload, CleanuparrJsonOptions.ExternalApiRead)!; + + result.Records[0].DownloadId.ShouldBeEmpty(); + result.Records[0].Protocol.ShouldBeEmpty(); + result.Records[0].TrackedDownloadState.ShouldBeEmpty(); + } + + [Fact] + public void QueueRecord_WithExplicitNullOnNullableProperty_StaysNull() + { + const string payload = """ + { + "totalRecords": 1, + "records": [{ "id": 1, "title": "T", "downloadId": "ABC", "downloadClient": null }] + } + """; + + QueueListResponse result = JsonSerializer.Deserialize(payload, CleanuparrJsonOptions.ExternalApiRead)!; + + result.Records[0].DownloadClient.ShouldBeNull(); + } + + [Fact] + public void Tag_WithoutLabel_UsesEmptyDefault() + { + List result = JsonSerializer.Deserialize>("""[{"id": 3}]""", CleanuparrJsonOptions.ExternalApiRead)!; + + result[0].Id.ShouldBe(3); + result[0].Label.ShouldBeEmpty(); + } + + [Fact] + public void QueueRecord_WithAllProperties_StillDeserializes() + { + const string payload = """ + { + "totalRecords": 1, + "records": [{ "id": 1, "title": "T", "downloadId": "ABC", "protocol": "torrent", "sizeleft": 100 }] + } + """; + + QueueListResponse result = JsonSerializer.Deserialize(payload, CleanuparrJsonOptions.ExternalApiRead)!; + + result.Records[0].DownloadId.ShouldBe("ABC"); + result.Records[0].Title.ShouldBe("T"); + result.Records[0].SizeLeft.ShouldBe(100); + } + + [Fact] + public void EmptyQueueResponse_Deserializes() + { + QueueListResponse result = JsonSerializer.Deserialize("{}", CleanuparrJsonOptions.ExternalApiRead)!; + + result.TotalRecords.ShouldBe(0); + result.Records.ShouldBeEmpty(); + } + + [Fact] + public void PositionalRecord_WithMissingConstructorParameter_Deserializes() + { + ArrCommandStatus result = JsonSerializer.Deserialize("""{"id": 5}""", CleanuparrJsonOptions.ExternalApiRead)!; + + result.Id.ShouldBe(5); + result.Status.ShouldBeNull(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/JsonStreamReader.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/JsonStreamReader.cs index d56738e4..894570b4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/JsonStreamReader.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/JsonStreamReader.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using Cleanuparr.Infrastructure.Json; namespace Cleanuparr.Infrastructure.Features.Arr; @@ -8,14 +9,6 @@ namespace Cleanuparr.Infrastructure.Features.Arr; /// internal static class JsonStreamReader { - private static readonly JsonSerializerOptions Options = new() - { - PropertyNameCaseInsensitive = true, - NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - }; - /// /// Streams items out of a top-level JSON array. /// @@ -23,7 +16,7 @@ internal static class JsonStreamReader Stream stream, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await foreach (T? item in JsonSerializer.DeserializeAsyncEnumerable(stream, Options, cancellationToken)) + await foreach (T? item in JsonSerializer.DeserializeAsyncEnumerable(stream, CleanuparrJsonOptions.ExternalApiRead, cancellationToken)) { if (item is not null) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index e059ea71..0410e1aa 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -284,7 +284,7 @@ public sealed class Seeker : IHandler if (instanceConfig.ActiveDownloadLimit > 0) { int activeDownloads = queueRecords - .Where(r => r.SizeLeft > 0) + .Where(r => r.SizeLeft > 0 && !string.IsNullOrEmpty(r.DownloadId)) .Select(r => r.DownloadId) .Distinct() .Count(); diff --git a/code/backend/Cleanuparr.Infrastructure/Json/CleanuparrJsonOptions.cs b/code/backend/Cleanuparr.Infrastructure/Json/CleanuparrJsonOptions.cs index 9682edeb..0c9f43e3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Json/CleanuparrJsonOptions.cs +++ b/code/backend/Cleanuparr.Infrastructure/Json/CleanuparrJsonOptions.cs @@ -1,25 +1,45 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Cleanuparr.Infrastructure.Json; +/// +/// The options that the application uses for JSON. +/// public static class CleanuparrJsonOptions { + /// + /// The options to read a response from an external API. + /// These options do not make a property necessary, because an external API can omit a field. + /// A field that is absent or null gets the default value of the property. + /// public static readonly JsonSerializerOptions ExternalApiRead = new() { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, + RespectRequiredConstructorParameters = false, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = { IgnoreRequiredProperties, IgnoreNullForNonNullable }, + }, }; + /// + /// The options to write a request to an external API. + /// public static readonly JsonSerializerOptions Outbound = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + /// + /// The options to write a payload for a notification provider. + /// public static readonly JsonSerializerOptions Notification = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -27,9 +47,54 @@ public static class CleanuparrJsonOptions Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + /// + /// The options to write a payload for a notification provider that needs the null fields. + /// public static readonly JsonSerializerOptions NotificationIncludeNulls = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + + private static void IgnoreRequiredProperties(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind is not JsonTypeInfoKind.Object) + { + return; + } + + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.IsRequired = false; + } + } + + /// + /// Keeps the default value of a property that is not nullable if the JSON field is null. + /// + /// The metadata of the type to change. + public static void IgnoreNullForNonNullable(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind is not JsonTypeInfoKind.Object) + { + return; + } + + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property.Set is null || property.IsSetNullable) + { + continue; + } + + Action originalSet = property.Set; + property.Set = (obj, value) => + { + if (value is not null) + { + originalSet(obj, value); + } + }; + } + } } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs index c39ba403..67ee2c1a 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs @@ -54,6 +54,7 @@ public sealed record SeekerInstanceConfig /// /// Skip proactive search cycles when the number of actively downloading items /// (SizeLeft > 0) in the arr queue is at or above this threshold. 0 = disabled. + /// The count includes only the items that a download client has. /// public int ActiveDownloadLimit { get; set; } = 3; diff --git a/docs/docs/configuration/seeker/index.mdx b/docs/docs/configuration/seeker/index.mdx index 0bafb017..145c9bca 100644 --- a/docs/docs/configuration/seeker/index.mdx +++ b/docs/docs/configuration/seeker/index.mdx @@ -203,6 +203,8 @@ manual-only When the number of items with bytes remaining to download (`SizeLeft > 0`) in the arr queue reaches this limit, the proactive search cycle is skipped for that instance. This prevents Seeker from triggering new searches while the download client is already busy. +Only items that a download client has are counted. Items that are still queued inside the *arr application, such as releases held by a delay profile, are not counted. + Set to `0` to disable this check and always run the proactive search regardless of queue activity.