diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs new file mode 100644 index 00000000..2191a29e --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs @@ -0,0 +1,8 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record ArrEpisodeFile +{ + public long Id { get; init; } + + public bool QualityCutoffNotMet { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs index f2c3325b..871b419e 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs @@ -14,5 +14,7 @@ public sealed record SearchableEpisode public bool HasFile { get; init; } + public long EpisodeFileId { get; init; } + public EpisodeFileInfo? EpisodeFile { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs index aa086311..546a6788 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs @@ -20,6 +20,11 @@ public interface ISonarrClient : IArrClient /// Task> GetQualityProfilesAsync(ArrInstance arrInstance); + /// + /// Fetches episode file metadata for a specific series, including quality cutoff status + /// + Task> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId); + /// /// Fetches custom format scores for episode files in batches /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index 2f16fd11..42a3f3aa 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -244,6 +244,22 @@ public class SonarrClient : ArrClient, ISonarrClient return JsonConvert.DeserializeObject>(responseBody) ?? []; } + public async Task> 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>(responseBody) ?? []; + } + public async Task> GetQualityProfilesAsync(ArrInstance arrInstance) { UriBuilder uriBuilder = new(arrInstance.Url); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index d42e8b92..49893673 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -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 seriesHistory) + List seriesHistory, + string seriesTitle) { List episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, seriesId); + // Fetch episode file metadata to determine cutoff status from the dedicated episodefile endpoint + HashSet cutoffNotMetFileIds = []; + if (config.UseCutoff) + { + List 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? 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 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,