Fix external API response handling (#684)

This commit is contained in:
Flaminel authored and GitHub committed 2026-07-28 15:20:02 +03:00
1 parent 075461391b
commit c4b3adcf87
22 files changed
+634 -107

No files matched your search

@@ -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<object, object?> originalSet = property.Set;
property.Set = (obj, value) =>
{
if (value is not null)
{
originalSet(obj, value);
}
};
}
options.TypeInfoResolver = options.TypeInfoResolver!
.WithAddedModifier(CleanuparrJsonOptions.IgnoreNullForNonNullable);
}
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
/// <summary>
/// An image of a series or a movie in Sonarr, Radarr or Whisparr.
/// </summary>
public record Image
{
public required string CoverType { get; init; }
public required Uri RemoteUrl { get; init; }
}
/// <summary>
/// The type of the image, for example "poster" or "screenshot".
/// </summary>
public string CoverType { get; init; } = string.Empty;
/// <summary>
/// The address of the image on the server of the metadata provider.
/// </summary>
public Uri? RemoteUrl { get; init; }
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
/// <summary>
/// An image of an album in Lidarr.
/// </summary>
public record LidarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}
/// <summary>
/// The type of the image, for example "cover".
/// </summary>
public string CoverType { get; init; } = string.Empty;
/// <summary>
/// The address of the image.
/// </summary>
public Uri? Url { get; init; }
}
@@ -1,7 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
/// <summary>
/// One page of the queue of an *arr application.
/// </summary>
public record QueueListResponse
{
public required int TotalRecords { get; init; }
public required IReadOnlyList<QueueRecord> Records { get; init; }
}
/// <summary>
/// The number of items in the queue, on this page and on the other pages.
/// </summary>
public int TotalRecords { get; init; }
/// <summary>
/// The items on this page.
/// </summary>
public IReadOnlyList<QueueRecord> Records { get; init; } = [];
}
@@ -1,42 +1,126 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
/// <summary>
/// One item in the queue of an *arr application.
/// The *arr applications share this type, and each one sets only its own fields.
/// </summary>
public sealed record QueueRecord
{
// Sonarr and Whisparr v2
/// <summary>
/// The ID of the series.
/// </summary>
public long SeriesId { get; init; }
/// <summary>
/// The ID of the episode.
/// </summary>
public long EpisodeId { get; init; }
/// <summary>
/// The number of the season.
/// </summary>
public long SeasonNumber { get; init; }
/// <summary>
/// The data about the series.
/// </summary>
public QueueSeries? Series { get; init; }
// Radarr and Whisparr v3
/// <summary>
/// The ID of the movie.
/// </summary>
public long MovieId { get; init; }
/// <summary>
/// The data about the movie.
/// </summary>
public QueueMovie? Movie { get; init; }
// Lidarr
/// <summary>
/// The ID of the artist.
/// </summary>
public long ArtistId { get; init; }
/// <summary>
/// The ID of the album.
/// </summary>
public long AlbumId { get; init; }
/// <summary>
/// The data about the album.
/// </summary>
public QueueAlbum? Album { get; init; }
// Readarr
/// <summary>
/// The ID of the author.
/// </summary>
public long AuthorId { get; init; }
/// <summary>
/// The ID of the book.
/// </summary>
public long BookId { get; init; }
/// <summary>
/// The data about the book.
/// </summary>
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; }
/// <summary>
/// The name of the release.
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// The status of the queue item, for example "downloading" or "delay".
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// The status of the tracked download, for example "warning".
/// A pending release does not have a tracked download, and the value is then empty.
/// </summary>
public string TrackedDownloadStatus { get; init; } = string.Empty;
/// <summary>
/// The state of the tracked download, for example "importBlocked".
/// A pending release does not have a tracked download, and the value is then empty.
/// </summary>
public string TrackedDownloadState { get; init; } = string.Empty;
/// <summary>
/// The messages that tell more about the status of the queue item.
/// </summary>
public List<TrackedDownloadStatusMessage>? StatusMessages { get; init; }
public required string DownloadId { get; init; }
/// <summary>
/// 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.
/// </summary>
public string DownloadId { get; init; } = string.Empty;
/// <summary>
/// The name of the download client that has the item.
/// </summary>
public string? DownloadClient { get; init; }
public required string Protocol { get; init; }
public required long Id { get; init; }
/// <summary>
/// The protocol of the release, for example "torrent" or "usenet".
/// </summary>
public string Protocol { get; init; } = string.Empty;
/// <summary>
/// The ID of the queue item.
/// </summary>
public long Id { get; init; }
/// <summary>
/// The number of bytes that the download client must still get.
/// </summary>
public long SizeLeft { get; init; }
}
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
/// <summary>
/// An image of a book in Readarr.
/// </summary>
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}
/// <summary>
/// The type of the image, for example "cover".
/// </summary>
public string CoverType { get; init; } = string.Empty;
/// <summary>
/// The address of the image.
/// </summary>
public Uri? Url { get; init; }
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr;
/// <summary>
/// A tag of an *arr application.
/// </summary>
public sealed record Tag
{
public required long Id { get; init; }
public required string Label { get; init; }
}
/// <summary>
/// The ID of the tag.
/// </summary>
public long Id { get; init; }
/// <summary>
/// The name of the tag.
/// </summary>
public string Label { get; init; } = string.Empty;
}
@@ -2,11 +2,21 @@
namespace Cleanuparr.Domain.Entities.Deluge.Response;
/// <summary>
/// The root of the file tree of a torrent in Deluge.
/// </summary>
public sealed record DelugeContents
{
/// <summary>
/// The child nodes of the root.
/// </summary>
[JsonPropertyName("contents")]
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; }
/// <summary>
/// The type of the root.
/// The value is always "dir".
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } // Always "dir" for the root
public string Type { get; set; } = string.Empty;
}
@@ -2,11 +2,20 @@
namespace Cleanuparr.Domain.Entities.Deluge.Response;
/// <summary>
/// The error of a response from Deluge.
/// </summary>
public sealed record DelugeError
{
/// <summary>
/// The text of the error.
/// </summary>
[JsonPropertyName("message")]
public String Message { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>
/// The code of the error.
/// </summary>
[JsonPropertyName("code")]
public int Code { get; set; }
}
@@ -1,33 +1,68 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Deluge.Response;
/// <summary>
/// One node in the file tree of a torrent in Deluge.
/// A node is a file or a directory, and the value of <see cref="Type"/> tells which one it is.
/// Deluge sends the file fields only on a file node.
/// </summary>
public class DelugeFileOrDirectory
{
/// <summary>
/// The type of the node.
/// The value is "file" or "dir".
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } // "file" or "dir"
public string Type { get; set; } = string.Empty;
/// <summary>
/// The child nodes of a directory node.
/// </summary>
[JsonPropertyName("contents")]
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; } // Recursive property for directories
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; set; }
public int Index { get; set; }
/// <summary>
/// The path of the node in the torrent.
/// </summary>
[JsonPropertyName("path")]
public string Path { get; set; }
public string Path { get; set; } = string.Empty;
/// <summary>
/// The size of the node in bytes.
/// </summary>
[JsonPropertyName("size")]
public int? Size { get; set; }
/// <summary>
/// The offset of the file in the data of the torrent.
/// </summary>
[JsonPropertyName("offset")]
public int? Offset { get; set; }
/// <summary>
/// The part of the node that Deluge has, as a value from 0 to 100.
/// </summary>
[JsonPropertyName("progress")]
public double? Progress { get; set; }
/// <summary>
/// The download priority of the node.
/// A value of 0 tells Deluge to skip the file.
/// </summary>
[JsonPropertyName("priority")]
public required int Priority { get; set; }
public int Priority { get; set; }
/// <summary>
/// The progress of each file below a directory node.
/// </summary>
[JsonPropertyName("progresses")]
public List<double> Progresses { get; set; }
}
public List<double> Progresses { get; set; } = [];
}
@@ -1,49 +1,104 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Deluge.Response;
/// <summary>
/// The status of a torrent in Deluge.
/// Deluge sends only the fields that the request asks for.
/// </summary>
public sealed record DownloadStatus
{
/// <summary>
/// The hash of the torrent.
/// </summary>
public string? Hash { get; init; }
/// <summary>
/// The state of the torrent, for example <see cref="DelugeState.Seeding"/>.
/// </summary>
public DelugeState State { get; init; }
/// <summary>
/// The name of the torrent.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// The number of seconds that the download needs to finish.
/// </summary>
public ulong Eta { get; init; }
/// <summary>
/// The download speed in bytes each second.
/// </summary>
[JsonPropertyName("download_payload_rate")]
public long DownloadSpeed { get; init; }
/// <summary>
/// True if the tracker of the torrent is private.
/// </summary>
public bool Private { get; init; }
/// <summary>
/// The size of the torrent in bytes.
/// </summary>
[JsonPropertyName("total_size")]
public long Size { get; init; }
/// <summary>
/// The number of bytes that Deluge has.
/// </summary>
[JsonPropertyName("total_done")]
public long TotalDone { get; init; }
/// <summary>
/// True if the download is complete.
/// </summary>
[JsonPropertyName("is_finished")]
public bool IsFinished { get; init; }
/// <summary>
/// The label of the torrent.
/// </summary>
public string? Label { get; set; }
/// <summary>
/// The number of seconds that the torrent has been in the seed state.
/// </summary>
[JsonPropertyName("seeding_time")]
public long SeedingTime { get; init; }
/// <summary>
/// The number of bytes that Deluge sent, divided by the number of bytes that Deluge got.
/// </summary>
public float Ratio { get; init; }
/// <summary>
/// The number of seeds that the tracker reports.
/// </summary>
[JsonPropertyName("total_seeds")]
public int TotalSeeds { get; init; }
public required IReadOnlyList<Tracker> Trackers { get; init; }
/// <summary>
/// The trackers of the torrent.
/// </summary>
public IReadOnlyList<Tracker> Trackers { get; init; } = [];
/// <summary>
/// The directory that holds the data of the torrent.
/// </summary>
[JsonPropertyName("download_location")]
public required string DownloadLocation { get; init; }
public string DownloadLocation { get; init; } = string.Empty;
}
/// <summary>
/// A tracker of a torrent in Deluge.
/// </summary>
public sealed record Tracker
{
public required string Url { get; init; }
/// <summary>
/// The address of the tracker.
/// </summary>
public string Url { get; init; } = string.Empty;
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Radarr;
namespace Cleanuparr.Domain.Entities.Radarr;
/// <summary>
/// A movie in Radarr or Whisparr v3.
/// </summary>
public sealed record Movie
{
public required long Id { get; init; }
public required string Title { get; init; }
}
/// <summary>
/// The ID of the movie.
/// </summary>
public long Id { get; init; }
/// <summary>
/// The name of the movie.
/// </summary>
public string Title { get; init; } = string.Empty;
}
@@ -1,12 +1,27 @@
namespace Cleanuparr.Domain.Entities.Readarr;
/// <summary>
/// A book in Readarr.
/// </summary>
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
/// <summary>
/// The ID of the book.
/// </summary>
public long Id { get; init; }
/// <summary>
/// The name of the book.
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// The ID of the author of the book.
/// </summary>
public long AuthorId { get; set; }
/// <summary>
/// The data about the author of the book.
/// </summary>
public Author Author { get; set; } = new();
}
}
@@ -1,8 +1,17 @@
namespace Cleanuparr.Domain.Entities.Sonarr;
namespace Cleanuparr.Domain.Entities.Sonarr;
/// <summary>
/// A series in Sonarr or Whisparr v2.
/// </summary>
public sealed record Series
{
public required long Id { get; init; }
public required string Title { get; init; }
}
/// <summary>
/// The ID of the series.
/// </summary>
public long Id { get; init; }
/// <summary>
/// The name of the series.
/// </summary>
public string Title { get; init; } = string.Empty;
}
@@ -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);
}
}
@@ -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<IArrClient>();
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<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci => ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2)(pendingReleases));
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs.FirstAsync();
instanceConfig.LastProcessedAt.ShouldNotBeNull();
}
[Fact]
public async Task ExecuteAsync_Radarr_ExcludesMoviesAlreadyInQueue()
{
@@ -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<QueueListResponse>(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<QueueListResponse>(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<QueueListResponse>(payload, CleanuparrJsonOptions.ExternalApiRead)!;
result.Records[0].DownloadClient.ShouldBeNull();
}
[Fact]
public void Tag_WithoutLabel_UsesEmptyDefault()
{
List<Tag> result = JsonSerializer.Deserialize<List<Tag>>("""[{"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<QueueListResponse>(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<QueueListResponse>("{}", CleanuparrJsonOptions.ExternalApiRead)!;
result.TotalRecords.ShouldBe(0);
result.Records.ShouldBeEmpty();
}
[Fact]
public void PositionalRecord_WithMissingConstructorParameter_Deserializes()
{
ArrCommandStatus result = JsonSerializer.Deserialize<ArrCommandStatus>("""{"id": 5}""", CleanuparrJsonOptions.ExternalApiRead)!;
result.Id.ShouldBe(5);
result.Status.ShouldBeNull();
}
}
@@ -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;
/// </summary>
internal static class JsonStreamReader
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// Streams items out of a top-level JSON array.
/// </summary>
@@ -23,7 +16,7 @@ internal static class JsonStreamReader
Stream stream,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (T? item in JsonSerializer.DeserializeAsyncEnumerable<T>(stream, Options, cancellationToken))
await foreach (T? item in JsonSerializer.DeserializeAsyncEnumerable<T>(stream, CleanuparrJsonOptions.ExternalApiRead, cancellationToken))
{
if (item is not null)
{
@@ -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();
@@ -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;
/// <summary>
/// The options that the application uses for JSON.
/// </summary>
public static class CleanuparrJsonOptions
{
/// <summary>
/// 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.
/// </summary>
public static readonly JsonSerializerOptions ExternalApiRead = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
RespectRequiredConstructorParameters = false,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { IgnoreRequiredProperties, IgnoreNullForNonNullable },
},
};
/// <summary>
/// The options to write a request to an external API.
/// </summary>
public static readonly JsonSerializerOptions Outbound = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// The options to write a payload for a notification provider.
/// </summary>
public static readonly JsonSerializerOptions Notification = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -27,9 +47,54 @@ public static class CleanuparrJsonOptions
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// The options to write a payload for a notification provider that needs the null fields.
/// </summary>
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;
}
}
/// <summary>
/// Keeps the default value of a property that is not nullable if the JSON field is null.
/// </summary>
/// <param name="typeInfo">The metadata of the type to change.</param>
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<object, object?> originalSet = property.Set;
property.Set = (obj, value) =>
{
if (value is not null)
{
originalSet(obj, value);
}
};
}
}
}
@@ -54,6 +54,7 @@ public sealed record SeekerInstanceConfig
/// <summary>
/// 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.
/// </summary>
public int ActiveDownloadLimit { get; set; } = 3;
+2
View File
@@ -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.
</ConfigSection>