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);