fixed Sonarr cutoff unmet and added logs for search reason

This commit is contained in:
Flaminel
2026-03-22 21:29:37 +02:00
parent 29059b7323
commit 33e06b59ec
5 changed files with 92 additions and 9 deletions

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrEpisodeFile
{
public long Id { get; init; }
public bool QualityCutoffNotMet { get; init; }
}

View File

@@ -14,5 +14,7 @@ public sealed record SearchableEpisode
public bool HasFile { get; init; }
public long EpisodeFileId { get; init; }
public EpisodeFileInfo? EpisodeFile { get; init; }
}

View File

@@ -20,6 +20,11 @@ public interface ISonarrClient : IArrClient
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches episode file metadata for a specific series, including quality cutoff status
/// </summary>
Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId);
/// <summary>
/// Fetches custom format scores for episode files in batches
/// </summary>

View File

@@ -244,6 +244,22 @@ public class SonarrClient : ArrClient, ISonarrClient
return JsonConvert.DeserializeObject<List<SearchableEpisode>>(responseBody) ?? [];
}
public async Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile";
uriBuilder.Query = $"seriesId={seriesId}";
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<List<ArrEpisodeFile>>(responseBody) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);

View File

@@ -365,15 +365,15 @@ public sealed class Seeker : IHandler
}
// Apply filters — UseCutoff and UseCustomFormatScore are OR-ed: an item qualifies if it fails the quality cutoff OR the CF score cutoff.
// Items without a cached CF score pass the CF filter.
// Items without cutoff data or a cached CF score are excluded from the respective filter.
var candidates = movies
.Where(m => m.Status is "released")
.Where(m => !config.MonitoredOnly || m.Monitored)
.Where(m => instanceConfig.SkipTags.Count == 0 || !m.Tags.Any(instanceConfig.SkipTags.Contains))
.Where(m => !m.HasFile
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|| (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? true))
|| (config.UseCustomFormatScore && (cfScores == null || !cfScores.TryGetValue(m.Id, out var entry) || entry.CurrentScore < entry.CutoffScore)))
|| (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? false))
|| (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(m.Id, out var entry) && entry.CurrentScore < entry.CutoffScore))
.ToList();
instanceConfig.TotalEligibleItems = candidates.Count;
@@ -412,6 +412,18 @@ public sealed class Seeker : IHandler
.Select(m => m.Title)
.ToList();
foreach (long movieId in selectedIds)
{
SearchableMovie movie = candidates.First(m => m.Id == movieId);
string reason = !movie.HasFile
? "missing file"
: config.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false)
? "does not meet quality cutoff"
: "custom format score below cutoff";
_logger.LogDebug("Selected '{Title}' for search on {InstanceName}: {Reason}",
movie.Title, arrInstance.Name, reason);
}
return (selectedIds, selectedNames, allLibraryIds);
}
@@ -464,12 +476,13 @@ public sealed class Seeker : IHandler
.Where(h => h.ExternalItemId == seriesId)
.ToList();
string seriesTitle = candidates.First(s => s.Id == seriesId).Title;
(SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode) =
await BuildSonarrSearchItemAsync(config, arrInstance, seriesId, seriesHistory);
await BuildSonarrSearchItemAsync(config, arrInstance, seriesId, seriesHistory, seriesTitle);
if (searchItem is not null)
{
string seriesTitle = candidates.First(s => s.Id == seriesId).Title;
string displayName = $"{seriesTitle} S{searchItem.Id:D2}";
int seasonNumber = (int)searchItem.Id;
@@ -512,10 +525,22 @@ public sealed class Seeker : IHandler
SeekerConfig config,
ArrInstance arrInstance,
long seriesId,
List<SeekerHistory> seriesHistory)
List<SeekerHistory> seriesHistory,
string seriesTitle)
{
List<SearchableEpisode> episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, seriesId);
// Fetch episode file metadata to determine cutoff status from the dedicated episodefile endpoint
HashSet<long> cutoffNotMetFileIds = [];
if (config.UseCutoff)
{
List<ArrEpisodeFile> episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, seriesId);
cutoffNotMetFileIds = episodeFiles
.Where(f => f.QualityCutoffNotMet)
.Select(f => f.Id)
.ToHashSet();
}
// Load cached CF scores for this series when custom format score filtering is enabled
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
if (config.UseCustomFormatScore)
@@ -528,14 +553,15 @@ public sealed class Seeker : IHandler
.ToDictionaryAsync(e => e.EpisodeId);
}
// Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed
// Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed.
// Cutoff status comes from the episodefile endpoint; items without a cached CF score are excluded.
var qualifying = episodes
.Where(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value <= DateTime.UtcNow)
.Where(e => !config.MonitoredOnly || e.Monitored)
.Where(e => !e.HasFile
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|| (config.UseCutoff && (e.EpisodeFile?.QualityCutoffNotMet ?? true))
|| (config.UseCustomFormatScore && (cfScores == null || !cfScores.TryGetValue(e.Id, out var entry) || entry.CurrentScore < entry.CutoffScore)))
|| (config.UseCutoff && cutoffNotMetFileIds.Contains(e.EpisodeFileId))
|| (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(e.Id, out var entry) && entry.CurrentScore < entry.CutoffScore))
.OrderBy(e => e.SeasonNumber)
.ThenBy(e => e.EpisodeNumber)
.ToList();
@@ -570,6 +596,32 @@ public sealed class Seeker : IHandler
.OrderBy(_ => Random.Shared.Next())
.First();
// Log why this season was selected
var seasonEpisodes = qualifying.Where(e => e.SeasonNumber == selected.SeasonNumber).ToList();
int missingCount = seasonEpisodes.Count(e => !e.HasFile);
int cutoffCount = seasonEpisodes.Count(e => e.HasFile && cutoffNotMetFileIds.Contains(e.EpisodeFileId));
int cfCount = seasonEpisodes.Count(e => e.HasFile && cfScores != null
&& cfScores.TryGetValue(e.Id, out var cfEntry) && cfEntry.CurrentScore < cfEntry.CutoffScore);
List<string> reasons = [];
if (missingCount > 0)
{
reasons.Add($"{missingCount} missing");
}
if (cutoffCount > 0)
{
reasons.Add($"{cutoffCount} cutoff unmet");
}
if (cfCount > 0)
{
reasons.Add($"{cfCount} below CF score cutoff");
}
_logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}",
seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons));
SeriesSearchItem searchItem = new()
{
Id = selected.SeasonNumber,