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;