fixed movies and series endpoint calls

This commit is contained in:
Flaminel
2026-03-21 18:00:02 +02:00
parent 0f2f9249bf
commit 0974b54b96
6 changed files with 86 additions and 167 deletions

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record PagedResponse<T>
{
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalRecords { get; init; }
public List<T> Records { get; init; } = [];
}

View File

@@ -15,11 +15,6 @@ public interface IRadarrClient : IArrClient
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches movies from a Radarr instance with paging support
/// </summary>
Task<PagedResponse<SearchableMovie>> GetMoviesPagedAsync(ArrInstance arrInstance, int page, int pageSize);
/// <summary>
/// Fetches custom format scores for movie files in batches
/// </summary>

View File

@@ -20,11 +20,6 @@ public interface ISonarrClient : IArrClient
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches series from a Sonarr instance with paging support
/// </summary>
Task<PagedResponse<SearchableSeries>> GetSeriesPagedAsync(ArrInstance arrInstance, int page, int pageSize);
/// <summary>
/// Fetches custom format scores for episode files in batches
/// </summary>

View File

@@ -150,28 +150,14 @@ public class RadarrClient : ArrClient, IRadarrClient
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<SearchableMovie>>(responseBody) ?? [];
}
public async Task<PagedResponse<SearchableMovie>> GetMoviesPagedAsync(ArrInstance arrInstance, int page, int pageSize)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie";
uriBuilder.Query = $"page={page}&pageSize={pageSize}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<PagedResponse<SearchableMovie>>(responseBody)
?? new PagedResponse<SearchableMovie>();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableMovie>>(reader) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)

View File

@@ -218,28 +218,14 @@ public class SonarrClient : ArrClient, ISonarrClient
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<SearchableSeries>>(responseBody) ?? [];
}
public async Task<PagedResponse<SearchableSeries>> GetSeriesPagedAsync(ArrInstance arrInstance, int page, int pageSize)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series";
uriBuilder.Query = $"page={page}&pageSize={pageSize}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<PagedResponse<SearchableSeries>>(responseBody)
?? new PagedResponse<SearchableSeries>();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableSeries>>(reader) ?? [];
}
public async Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId)

View File

@@ -13,11 +13,10 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
/// <summary>
/// Periodically syncs custom format scores from Radarr/Sonarr instances.
/// Tracks score changes over time for dashboard display and Seeker filtering.
/// Uses paged API fetching to handle large libraries efficiently.
/// </summary>
public sealed class CustomFormatScoreSyncer : IHandler
{
private const int PageSize = 200;
private const int ChunkSize = 200;
private static readonly TimeSpan HistoryRetention = TimeSpan.FromDays(120);
private readonly ILogger<CustomFormatScoreSyncer> _logger;
@@ -45,7 +44,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
if (!config.UseCustomFormatScore)
{
_logger.LogDebug("Custom format score tracking is disabled");
_logger.LogTrace("Custom format score tracking is disabled");
return;
}
@@ -103,68 +102,47 @@ public sealed class CustomFormatScoreSyncer : IHandler
List<ArrQualityProfile> profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance);
Dictionary<int, ArrQualityProfile> profileMap = profiles.ToDictionary(p => p.Id);
List<SearchableMovie> allMovies = await _radarrClient.GetAllMoviesAsync(arrInstance);
List<(SearchableMovie Movie, long FileId)> moviesWithFiles = allMovies
.Where(m => m.HasFile && m.MovieFile is not null)
.Select(m => (Movie: m, FileId: m.MovieFile!.Id))
.Where(x => x.FileId > 0)
.ToList();
DateTime syncStartTime = DateTime.UtcNow;
int page = 1;
int totalSynced = 0;
int processedRecords = 0;
while (true)
foreach ((SearchableMovie Movie, long FileId)[] chunk in moviesWithFiles.Chunk(ChunkSize))
{
PagedResponse<SearchableMovie> response = await _radarrClient.GetMoviesPagedAsync(arrInstance, page, PageSize);
List<long> fileIds = chunk.Select(x => x.FileId).ToList();
Dictionary<long, int> scores = await _radarrClient.GetMovieFileScoresAsync(arrInstance, fileIds);
if (response.Records.Count == 0)
List<long> movieIds = chunk.Select(x => x.Movie.Id).ToList();
Dictionary<long, CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
.Where(e => e.ArrInstanceId == arrInstance.Id
&& e.ItemType == InstanceType.Radarr
&& movieIds.Contains(e.ExternalItemId))
.ToDictionaryAsync(e => e.ExternalItemId);
foreach ((SearchableMovie movie, long fileId) in chunk)
{
break;
}
List<(SearchableMovie Movie, long FileId)> moviesWithFiles = response.Records
.Where(m => m.HasFile && m.MovieFile is not null)
.Select(m => (Movie: m, FileId: m.MovieFile!.Id))
.Where(x => x.FileId > 0)
.ToList();
if (moviesWithFiles.Count > 0)
{
List<long> fileIds = moviesWithFiles.Select(x => x.FileId).ToList();
Dictionary<long, int> scores = await _radarrClient.GetMovieFileScoresAsync(arrInstance, fileIds);
// Load existing entries for this page's movie IDs
List<long> movieIds = moviesWithFiles.Select(x => x.Movie.Id).ToList();
Dictionary<long, CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
.Where(e => e.ArrInstanceId == arrInstance.Id
&& e.ItemType == InstanceType.Radarr
&& movieIds.Contains(e.ExternalItemId))
.ToDictionaryAsync(e => e.ExternalItemId);
foreach ((SearchableMovie movie, long fileId) in moviesWithFiles)
if (!scores.TryGetValue(fileId, out int cfScore))
{
if (!scores.TryGetValue(fileId, out int cfScore))
{
continue;
}
profileMap.TryGetValue(movie.QualityProfileId, out ArrQualityProfile? profile);
int cutoffScore = profile?.CutoffFormatScore ?? 0;
string profileName = profile?.Name ?? "Unknown";
existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, syncStartTime);
totalSynced++;
continue;
}
await _dataContext.SaveChangesAsync();
profileMap.TryGetValue(movie.QualityProfileId, out ArrQualityProfile? profile);
int cutoffScore = profile?.CutoffFormatScore ?? 0;
string profileName = profile?.Name ?? "Unknown";
existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, syncStartTime);
totalSynced++;
}
processedRecords += response.Records.Count;
if (processedRecords >= response.TotalRecords)
{
break;
}
page++;
await Task.Delay(200);
await _dataContext.SaveChangesAsync();
}
await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Radarr, syncStartTime);
@@ -178,69 +156,33 @@ public sealed class CustomFormatScoreSyncer : IHandler
List<ArrQualityProfile> profiles = await _sonarrClient.GetQualityProfilesAsync(arrInstance);
Dictionary<int, ArrQualityProfile> profileMap = profiles.ToDictionary(p => p.Id);
List<SearchableSeries> allSeries = await _sonarrClient.GetAllSeriesAsync(arrInstance);
DateTime syncStartTime = DateTime.UtcNow;
int page = 1;
int totalSynced = 0;
int processedRecords = 0;
while (true)
foreach (SearchableSeries[] chunk in allSeries.Chunk(ChunkSize))
{
PagedResponse<SearchableSeries> response = await _sonarrClient.GetSeriesPagedAsync(arrInstance, page, PageSize);
// Collect all episodes with files for this chunk of series
List<(SearchableSeries Series, SearchableEpisode Episode, long FileId)> itemsInChunk = [];
if (response.Records.Count == 0)
{
break;
}
foreach (SearchableSeries series in response.Records)
foreach (SearchableSeries series in chunk)
{
try
{
List<SearchableEpisode> episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, series.Id);
List<(SearchableEpisode Episode, long FileId)> episodesWithFiles = episodes
.Where(e => e.HasFile && e.EpisodeFile is not null)
.Select(e => (Episode: e, FileId: e.EpisodeFile!.Id))
.Where(x => x.FileId > 0)
.ToList();
if (episodesWithFiles.Count == 0)
foreach (SearchableEpisode episode in episodes)
{
continue;
}
List<long> fileIds = episodesWithFiles.Select(x => x.FileId).ToList();
Dictionary<long, int> scores = await _sonarrClient.GetEpisodeFileScoresAsync(arrInstance, fileIds);
profileMap.TryGetValue(series.QualityProfileId, out ArrQualityProfile? profile);
int cutoffScore = profile?.CutoffFormatScore ?? 0;
string profileName = profile?.Name ?? "Unknown";
// Load existing entries for this series
Dictionary<(long, long), CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
.Where(e => e.ArrInstanceId == arrInstance.Id
&& e.ItemType == InstanceType.Sonarr
&& e.ExternalItemId == series.Id)
.ToDictionaryAsync(e => (e.ExternalItemId, e.EpisodeId));
foreach ((SearchableEpisode episode, long fileId) in episodesWithFiles)
{
if (!scores.TryGetValue(fileId, out int cfScore))
if (episode.HasFile && episode.EpisodeFile is not null && episode.EpisodeFile.Id > 0)
{
continue;
itemsInChunk.Add((series, episode, episode.EpisodeFile.Id));
}
string title = $"{series.Title} S{episode.SeasonNumber:D2}E{episode.EpisodeNumber:D2}";
existingEntries.TryGetValue((series.Id, episode.Id), out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, syncStartTime);
totalSynced++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to sync CF scores for series {SeriesId} on {InstanceName}",
_logger.LogWarning(ex, "Failed to fetch episodes for series {SeriesId} on {InstanceName}",
series.Id, arrInstance.Name);
}
@@ -248,17 +190,41 @@ public sealed class CustomFormatScoreSyncer : IHandler
await Task.Delay(Random.Shared.Next(500, 1500));
}
// Save per page to avoid large transactions
await _dataContext.SaveChangesAsync();
processedRecords += response.Records.Count;
if (processedRecords >= response.TotalRecords)
if (itemsInChunk.Count == 0)
{
break;
continue;
}
page++;
List<long> fileIds = itemsInChunk.Select(x => x.FileId).ToList();
Dictionary<long, int> scores = await _sonarrClient.GetEpisodeFileScoresAsync(arrInstance, fileIds);
List<long> seriesIds = itemsInChunk.Select(x => x.Series.Id).Distinct().ToList();
Dictionary<(long, long), CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
.Where(e => e.ArrInstanceId == arrInstance.Id
&& e.ItemType == InstanceType.Sonarr
&& seriesIds.Contains(e.ExternalItemId))
.ToDictionaryAsync(e => (e.ExternalItemId, e.EpisodeId));
foreach ((SearchableSeries series, SearchableEpisode episode, long fileId) in itemsInChunk)
{
if (!scores.TryGetValue(fileId, out int cfScore))
{
continue;
}
profileMap.TryGetValue(series.QualityProfileId, out ArrQualityProfile? profile);
int cutoffScore = profile?.CutoffFormatScore ?? 0;
string profileName = profile?.Name ?? "Unknown";
string title = $"{series.Title} S{episode.SeasonNumber:D2}E{episode.EpisodeNumber:D2}";
existingEntries.TryGetValue((series.Id, episode.Id), out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, syncStartTime);
totalSynced++;
}
await _dataContext.SaveChangesAsync();
}
await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Sonarr, syncStartTime);