mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added wanted list reads to sonarr and radarr
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user