mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-04-02 05:21:16 -04:00
Fix tags not being excluded by Seeker (#533)
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SeriesStatistics
|
||||
{
|
||||
public int EpisodeFileCount { get; init; }
|
||||
|
||||
public int EpisodeCount { get; init; }
|
||||
}
|
||||
8
code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs
Normal file
8
code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -330,4 +330,6 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -277,4 +277,9 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user