mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 21:37:53 -04:00
Fix external API response handling (#684)
This commit is contained in:
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;
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user