Fix tags not being excluded by Seeker (#533)

This commit is contained in:
Flaminel
2026-03-31 12:22:45 +03:00
committed by GitHub
parent 7122b16a7a
commit 868406c95c
14 changed files with 116 additions and 16 deletions

View File

@@ -12,7 +12,7 @@ public sealed record SearchableMovie
public MovieFileInfo? MovieFile { get; init; }
public List<string> Tags { get; init; } = [];
public List<long> Tags { get; init; } = [];
public int QualityProfileId { get; init; }

View File

@@ -10,20 +10,11 @@ public sealed record SearchableSeries
public bool Monitored { get; init; }
public List<string> Tags { get; init; } = [];
public List<long> Tags { get; init; } = [];
public DateTime? Added { get; init; }
public string Status { get; init; } = string.Empty;
public SeriesStatistics? Statistics { get; init; }
}
public sealed record SeriesStatistics
{
public int EpisodeFileCount { get; init; }
public int EpisodeCount { get; init; }
public double PercentOfEpisodes { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SeriesStatistics
{
public int EpisodeFileCount { get; init; }
public int EpisodeCount { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record Tag
{
public required long Id { get; init; }
public required string Label { get; init; }
}

View File

@@ -56,6 +56,14 @@ public class SeekerTests : IDisposable
// Default: dry run disabled
_dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(false);
// Default: GetAllTagsAsync returns empty list
_radarrClient
.Setup(x => x.GetAllTagsAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync([]);
_sonarrClient
.Setup(x => x.GetAllTagsAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync([]);
// Default: PublishSearchTriggered returns a Guid
_fixture.EventPublisher
.Setup(x => x.PublishSearchTriggered(
@@ -672,8 +680,16 @@ public class SeekerTests : IDisposable
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = ["movies"] },
new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = ["no-search", "movies"] }
new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = [1] },
new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = [2, 1] }
]);
_radarrClient
.Setup(x => x.GetAllTagsAsync(radarrInstance))
.ReturnsAsync(
[
new Tag { Id = 1, Label = "movies" },
new Tag { Id = 2, Label = "no-search" }
]);
HashSet<SearchItem>? capturedSearchItems = null;

View File

@@ -330,4 +330,6 @@ public abstract class ArrClient : IArrClient
return true;
}
public abstract Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
}

View File

@@ -45,4 +45,6 @@ public interface IArrClient
/// Items that are completed, import-blocked, or otherwise finished are not counted.
/// </summary>
Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance);
Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -155,4 +156,9 @@ public class LidarrClient : ArrClient, ILidarrClient
return [new LidarrCommand { Name = albumSearch, AlbumIds = items.Select(i => i.Id).ToList() }];
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}

View File

@@ -157,6 +157,23 @@ public class RadarrClient : ArrClient, IRadarrClient
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableMovie>>(reader) ?? [];
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/tag";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
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<Tag>>(reader) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
{

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -145,4 +146,9 @@ public class ReadarrClient : ArrClient, IReadarrClient
return await DeserializeStreamAsync<Book>(response);
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}

View File

@@ -227,6 +227,23 @@ public class SonarrClient : ArrClient, ISonarrClient
return serializer.Deserialize<List<SearchableSeries>>(reader) ?? [];
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/tag";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
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<Tag>>(reader) ?? [];
}
public async Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);

View File

@@ -277,4 +277,9 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return commands;
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Domain.Entities.Whisparr;
@@ -146,4 +147,9 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return await DeserializeStreamAsync<Movie>(response);
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}

View File

@@ -422,8 +422,12 @@ public sealed class Seeker : IHandler
HashSet<long> queuedMovieIds)
{
List<SearchableMovie> movies = await _radarrClient.GetAllMoviesAsync(arrInstance);
List<Tag> tags = await _radarrClient.GetAllTagsAsync(arrInstance);
List<long> allLibraryIds = movies.Select(m => m.Id).ToList();
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
// Load cached CF scores when custom format score filtering is enabled
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
if (config.UseCustomFormatScore)
@@ -441,7 +445,11 @@ public sealed class Seeker : IHandler
.Where(m => m.Status is "released")
.Where(m => IsMoviePastGracePeriod(m, graceCutoff))
.Where(m => !config.MonitoredOnly || m.Monitored)
.Where(m => instanceConfig.SkipTags.Count == 0 || !m.Tags.Any(instanceConfig.SkipTags.Contains))
.Where(m => instanceConfig.SkipTags.Count == 0 ||
!m.Tags
.Select(id => tagsById.TryGetValue(id, out var label) ? label : null)
.Any(label => label is not null && skipTagSet.Contains(label))
)
.Where(m => !m.HasFile
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|| (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? false))
@@ -543,14 +551,22 @@ public sealed class Seeker : IHandler
HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null)
{
List<SearchableSeries> series = await _sonarrClient.GetAllSeriesAsync(arrInstance);
List<Tag> tags = await _sonarrClient.GetAllTagsAsync(arrInstance);
List<long> allLibraryIds = series.Select(s => s.Id).ToList();
DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours);
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
// Apply filters
var candidates = series
.Where(s => s.Status is "continuing" or "ended" or "released")
.Where(s => !config.MonitoredOnly || s.Monitored)
.Where(s => instanceConfig.SkipTags.Count == 0 || !s.Tags.Any(instanceConfig.SkipTags.Contains))
.Where(s => instanceConfig.SkipTags.Count == 0 ||
!s.Tags
.Select(id => tagsById.TryGetValue(id, out var label) ? label : null)
.Any(label => label is not null && skipTagSet.Contains(label))
)
// Skip fully-downloaded series (unless quality upgrade filters active)
.Where(s => config.UseCutoff || config.UseCustomFormatScore
|| s.Statistics == null || s.Statistics.EpisodeCount == 0