diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrPagedResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrPagedResponse.cs
new file mode 100644
index 00000000..cdfac321
--- /dev/null
+++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrPagedResponse.cs
@@ -0,0 +1,17 @@
+namespace Cleanuparr.Domain.Entities.Arr;
+
+///
+/// One page of a paged *arr list response (e.g. wanted/missing, wanted/cutoff).
+///
+public record ArrPagedResponse
+{
+ ///
+ /// The number of items in the list, on this page and on the other pages.
+ ///
+ public int TotalRecords { get; init; }
+
+ ///
+ /// The items on this page.
+ ///
+ public IReadOnlyList Records { get; init; } = [];
+}
diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs
index 5fcad5d9..3e597f25 100644
--- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs
+++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs
@@ -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; }
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs
index 4edc7570..ce70e2fa 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs
@@ -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())));
+
+ // 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())));
+
+ // 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 response = page == 1
+ ? new ArrPagedResponse
+ {
+ TotalRecords = 2,
+ Records = [new SearchableMovie { Id = 1, Title = "Movie A" }],
+ }
+ : new ArrPagedResponse
+ {
+ TotalRecords = 2,
+ Records = [new SearchableMovie { Id = 2, Title = "Movie B" }],
+ };
+ return Task.FromResult(JsonResponse(response));
+ });
+
+ // Act
+ List 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(
+ () => _client.GetMissingMoviesAsync(_arrInstance, monitored: true));
+ exception.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ #endregion
+
private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs
index b6b884c3..ee701122 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs
@@ -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())));
+
+ // 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())));
+
+ // 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 response = page == 1
+ ? new ArrPagedResponse
+ {
+ TotalRecords = 2,
+ Records = [new SearchableEpisode { Id = 1, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1 }],
+ }
+ : new ArrPagedResponse
+ {
+ TotalRecords = 2,
+ Records = [new SearchableEpisode { Id = 2, SeriesId = 11, SeasonNumber = 1, EpisodeNumber = 2 }],
+ };
+ return Task.FromResult(JsonResponse(response));
+ });
+
+ // Act
+ List 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(
+ () => _client.GetMissingEpisodesAsync(_arrInstance, monitored: true));
+ exception.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ #endregion
+
private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
index f5e89a92..9719ff78 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
@@ -36,6 +36,16 @@ public abstract class ArrClient : IArrClient
///
protected const int QueuePageSize = 200;
+ ///
+ /// Page size used when paging through an *arr instance's wanted lists.
+ ///
+ protected const int WantedPageSize = 1000;
+
+ ///
+ /// Hard cap on pages read from a wanted list, so a stuck pagination loop can't run forever.
+ ///
+ private const int MaxWantedPages = 1000;
+
///
/// Command API path for this app's API version.
///
@@ -308,6 +318,71 @@ public abstract class ArrClient : IArrClient
return await DeserializeStreamAsync>(response, cancellationToken) ?? [];
}
+ ///
+ /// Reads every record of a paged wanted list (missing or cutoff) from an arr instance.
+ ///
+ /// The arr instance to query.
+ /// The wanted list to read, "missing" or "cutoff".
+ /// Only monitored items when true, only unmonitored items when false.
+ /// Cancellation token.
+ protected async Task> GetAllWantedAsync(
+ ArrInstance arrInstance,
+ string wantedKind,
+ bool monitored,
+ CancellationToken cancellationToken = default
+ )
+ {
+ List 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? wantedResponse = await DeserializeStreamAsync>(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;
+ }
+
///
public virtual ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => null;
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs
index aa4762e0..5a350833 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs
@@ -24,4 +24,14 @@ public interface IRadarrClient : IArrClient
/// Fetches the ids of movies Radarr grabbed since the given time.
///
Task> GetRecentlyGrabbedMovieIdsAsync(ArrInstance arrInstance, DateTimeOffset since, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches every movie Radarr lists as missing, filtered by monitored state.
+ ///
+ Task> GetMissingMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches every movie Radarr lists as a cutoff-unmet upgrade, filtered by monitored state.
+ ///
+ Task> GetCutoffUnmetMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs
index b261c1f3..70ad0cc5 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs
@@ -34,4 +34,14 @@ public interface ISonarrClient : IArrClient
/// Fetches the series/season pairs Sonarr grabbed an episode for since the given time.
///
Task> GetRecentlyGrabbedSeasonsAsync(ArrInstance arrInstance, DateTimeOffset since, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches every episode Sonarr lists as missing, filtered by monitored state.
+ ///
+ Task> GetMissingEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches every episode Sonarr lists as a cutoff-unmet upgrade, filtered by monitored state.
+ ///
+ Task> GetCutoffUnmetEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs
index d2588b6a..5d925065 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs
@@ -234,6 +234,14 @@ public class RadarrClient : ArrClient, IRadarrClient
.ToHashSet();
}
+ ///
+ public async Task> GetMissingMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
+ await GetAllWantedAsync(arrInstance, "missing", monitored, cancellationToken);
+
+ ///
+ public async Task> GetCutoffUnmetMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
+ await GetAllWantedAsync(arrInstance, "cutoff", monitored, cancellationToken);
+
private async Task GetMovie(ArrInstance arrInstance, long movieId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs
index d09c37b8..e3b48e8c 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs
@@ -350,6 +350,14 @@ public class SonarrClient : ArrClient, ISonarrClient
.ToHashSet();
}
+ ///
+ public async Task> GetMissingEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
+ await GetAllWantedAsync(arrInstance, "missing", monitored, cancellationToken);
+
+ ///
+ public async Task> GetCutoffUnmetEpisodesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) =>
+ await GetAllWantedAsync(arrInstance, "cutoff", monitored, cancellationToken);
+
private async Task?> GetEpisodesAsync(ArrInstance arrInstance, List episodeIds)
{
UriBuilder uriBuilder = new(arrInstance.Url);