added wanted list reads to sonarr and radarr

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent 3f29613499
commit 00a1cdeda7
9 files changed
+286

No files matched your search

@@ -0,0 +1,17 @@
namespace Cleanuparr.Domain.Entities.Arr;
/// <summary>
/// One page of a paged *arr list response (e.g. wanted/missing, wanted/cutoff).
/// </summary>
public record ArrPagedResponse<T>
{
/// <summary>
/// The number of items in the list, on this page and on the other pages.
/// </summary>
public int TotalRecords { get; init; }
/// <summary>
/// The items on this page.
/// </summary>
public IReadOnlyList<T> Records { get; init; } = [];
}
@@ -4,6 +4,8 @@ public sealed record SearchableEpisode
{
public long Id { get; init; }
public long SeriesId { get; init; }
public int SeasonNumber { get; init; }
public int EpisodeNumber { get; init; }
@@ -599,6 +599,84 @@ public class RadarrClientTests
#endregion
#region GetMissingMoviesAsync / GetCutoffUnmetMoviesAsync
[Fact]
public async Task GetMissingMoviesAsync_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableMovie>())));
// Act
await _client.GetMissingMoviesAsync(_arrInstance, monitored: true);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/missing");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetCutoffUnmetMoviesAsync_MonitoredFalse_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableMovie>())));
// Act
await _client.GetCutoffUnmetMoviesAsync(_arrInstance, monitored: false);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/cutoff");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=false");
}
[Fact]
public async Task GetMissingMoviesAsync_AccumulatesPagesUntilTotalRecordsReached()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) =>
{
int page = _httpMessageHandler.CapturedRequests.Count;
ArrPagedResponse<SearchableMovie> response = page == 1
? new ArrPagedResponse<SearchableMovie>
{
TotalRecords = 2,
Records = [new SearchableMovie { Id = 1, Title = "Movie A" }],
}
: new ArrPagedResponse<SearchableMovie>
{
TotalRecords = 2,
Records = [new SearchableMovie { Id = 2, Title = "Movie B" }],
};
return Task.FromResult(JsonResponse(response));
});
// Act
List<SearchableMovie> movies = await _client.GetMissingMoviesAsync(_arrInstance, monitored: true);
// Assert
movies.Count.ShouldBe(2);
movies[0].Title.ShouldBe("Movie A");
movies[1].Title.ShouldBe("Movie B");
_httpMessageHandler.CapturedRequests.Count.ShouldBe(2);
_httpMessageHandler.CapturedRequests[1].RequestUri!.Query.ShouldBe("?page=2&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetMissingMoviesAsync_TheArrRefused_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.NotFound);
// Act, Assert
HttpRequestException exception = await Should.ThrowAsync<HttpRequestException>(
() => _client.GetMissingMoviesAsync(_arrInstance, monitored: true));
exception.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
#endregion
private static HttpResponseMessage JsonResponse<T>(T body) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
@@ -763,6 +763,84 @@ public class SonarrClientTests
#endregion
#region GetMissingEpisodesAsync / GetCutoffUnmetEpisodesAsync
[Fact]
public async Task GetMissingEpisodesAsync_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableEpisode>())));
// Act
await _client.GetMissingEpisodesAsync(_arrInstance, monitored: true);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/missing");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetCutoffUnmetEpisodesAsync_MonitoredFalse_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableEpisode>())));
// Act
await _client.GetCutoffUnmetEpisodesAsync(_arrInstance, monitored: false);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/cutoff");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=false");
}
[Fact]
public async Task GetMissingEpisodesAsync_AccumulatesPagesAndParsesSeriesId()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) =>
{
int page = _httpMessageHandler.CapturedRequests.Count;
ArrPagedResponse<SearchableEpisode> response = page == 1
? new ArrPagedResponse<SearchableEpisode>
{
TotalRecords = 2,
Records = [new SearchableEpisode { Id = 1, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1 }],
}
: new ArrPagedResponse<SearchableEpisode>
{
TotalRecords = 2,
Records = [new SearchableEpisode { Id = 2, SeriesId = 11, SeasonNumber = 1, EpisodeNumber = 2 }],
};
return Task.FromResult(JsonResponse(response));
});
// Act
List<SearchableEpisode> episodes = await _client.GetMissingEpisodesAsync(_arrInstance, monitored: true);
// Assert
episodes.Count.ShouldBe(2);
episodes[0].SeriesId.ShouldBe(10L);
episodes[1].SeriesId.ShouldBe(11L);
_httpMessageHandler.CapturedRequests.Count.ShouldBe(2);
_httpMessageHandler.CapturedRequests[1].RequestUri!.Query.ShouldBe("?page=2&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetMissingEpisodesAsync_TheArrRefused_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.NotFound);
// Act, Assert
HttpRequestException exception = await Should.ThrowAsync<HttpRequestException>(
() => _client.GetMissingEpisodesAsync(_arrInstance, monitored: true));
exception.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
#endregion
private static HttpResponseMessage JsonResponse<T>(T body) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
@@ -36,6 +36,16 @@ public abstract class ArrClient : IArrClient
/// </summary>
protected const int QueuePageSize = 200;
/// <summary>
/// Page size used when paging through an *arr instance's wanted lists.
/// </summary>
protected const int WantedPageSize = 1000;
/// <summary>
/// Hard cap on pages read from a wanted list, so a stuck pagination loop can't run forever.
/// </summary>
private const int MaxWantedPages = 1000;
/// <summary>
/// Command API path for this app's API version.
/// </summary>
@@ -308,6 +318,71 @@ public abstract class ArrClient : IArrClient
return await DeserializeStreamAsync<List<ArrHistoryRecord>>(response, cancellationToken) ?? [];
}
/// <summary>
/// Reads every record of a paged wanted list (missing or cutoff) from an arr instance.
/// </summary>
/// <param name="arrInstance">The arr instance to query.</param>
/// <param name="wantedKind">The wanted list to read, "missing" or "cutoff".</param>
/// <param name="monitored">Only monitored items when true, only unmonitored items when false.</param>
/// <param name="cancellationToken">Cancellation token.</param>
protected async Task<List<T>> GetAllWantedAsync<T>(
ArrInstance arrInstance,
string wantedKind,
bool monitored,
CancellationToken cancellationToken = default
)
{
List<T> records = [];
int totalRecords = 0;
int page = 1;
string monitoredValue = monitored ? "true" : "false";
do
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/wanted/{wantedKind}";
uriBuilder.Query = $"page={page}&pageSize={WantedPageSize}&monitored={monitoredValue}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
try
{
response.EnsureSuccessStatusCode();
}
catch
{
_logger.LogError("wanted {Kind} failed | {Uri}", wantedKind, uriBuilder.Uri);
throw;
}
ArrPagedResponse<T>? wantedResponse = await DeserializeStreamAsync<ArrPagedResponse<T>>(response, cancellationToken);
if (wantedResponse is null || wantedResponse.Records.Count is 0)
{
break;
}
if (totalRecords is 0)
{
totalRecords = wantedResponse.TotalRecords;
}
records.AddRange(wantedResponse.Records);
if (records.Count >= totalRecords)
{
break;
}
page++;
} while (page <= MaxWantedPages);
return records;
}
/// <inheritdoc/>
public virtual ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => null;
@@ -24,4 +24,14 @@ public interface IRadarrClient : IArrClient
/// Fetches the ids of movies Radarr grabbed since the given time.
/// </summary>
Task<HashSet<long>> GetRecentlyGrabbedMovieIdsAsync(ArrInstance arrInstance, DateTimeOffset since, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches every movie Radarr lists as missing, filtered by monitored state.
/// </summary>
Task<List<SearchableMovie>> GetMissingMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches every movie Radarr lists as a cutoff-unmet upgrade, filtered by monitored state.
/// </summary>
Task<List<SearchableMovie>> GetCutoffUnmetMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
}
@@ -34,4 +34,14 @@ public interface ISonarrClient : IArrClient
/// Fetches the series/season pairs Sonarr grabbed an episode for since the given time.
/// </summary>
Task<HashSet<(long SeriesId, long SeasonNumber)>> GetRecentlyGrabbedSeasonsAsync(ArrInstance arrInstance, DateTimeOffset since, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches every episode Sonarr lists as missing, filtered by monitored state.
/// </summary>
Task<List<SearchableEpisode>> GetMissingEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches every episode Sonarr lists as a cutoff-unmet upgrade, filtered by monitored state.
/// </summary>
Task<List<SearchableEpisode>> GetCutoffUnmetEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
}
@@ -234,6 +234,14 @@ public class RadarrClient : ArrClient, IRadarrClient
.ToHashSet();
}
/// <inheritdoc/>
public async Task<List<SearchableMovie>> GetMissingMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
await GetAllWantedAsync<SearchableMovie>(arrInstance, "missing", monitored, cancellationToken);
/// <inheritdoc/>
public async Task<List<SearchableMovie>> GetCutoffUnmetMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
await GetAllWantedAsync<SearchableMovie>(arrInstance, "cutoff", monitored, cancellationToken);
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
@@ -350,6 +350,14 @@ public class SonarrClient : ArrClient, ISonarrClient
.ToHashSet();
}
/// <inheritdoc/>
public async Task<List<SearchableEpisode>> GetMissingEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
await GetAllWantedAsync<SearchableEpisode>(arrInstance, "missing", monitored, cancellationToken);
/// <inheritdoc/>
public async Task<List<SearchableEpisode>> GetCutoffUnmetEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
await GetAllWantedAsync<SearchableEpisode>(arrInstance, "cutoff", monitored, cancellationToken);
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{
UriBuilder uriBuilder = new(arrInstance.Url);