diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs index 2b49ec1c..b47b9fb1 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs @@ -12,7 +12,7 @@ public sealed record SearchableMovie public MovieFileInfo? MovieFile { get; init; } - public List Tags { get; init; } = []; + public List Tags { get; init; } = []; public int QualityProfileId { get; init; } diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs index bacd7fc6..0d02db6b 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs @@ -10,20 +10,11 @@ public sealed record SearchableSeries public bool Monitored { get; init; } - public List Tags { get; init; } = []; - + public List 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; } -} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesStatistics.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesStatistics.cs new file mode 100644 index 00000000..8e4282f9 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesStatistics.cs @@ -0,0 +1,8 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record SeriesStatistics +{ + public int EpisodeFileCount { get; init; } + + public int EpisodeCount { get; init; } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs new file mode 100644 index 00000000..43c3baae --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Tag.cs @@ -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; } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 44c2fbb0..27cbf655 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -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())) + .ReturnsAsync([]); + _sonarrClient + .Setup(x => x.GetAllTagsAsync(It.IsAny())) + .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? capturedSearchItems = null; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index 960c67ca..7232bba0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -330,4 +330,6 @@ public abstract class ArrClient : IArrClient return true; } + + public abstract Task> GetAllTagsAsync(ArrInstance arrInstance); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index 8d377718..8b436fe6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -45,4 +45,6 @@ public interface IArrClient /// Items that are completed, import-blocked, or otherwise finished are not counted. /// Task GetActiveDownloadCountAsync(ArrInstance arrInstance); + + Task> GetAllTagsAsync(ArrInstance arrInstance); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index 280d3513..7678782d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -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> GetAllTagsAsync(ArrInstance arrInstance) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index 7c8711d2..ee151468 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -157,6 +157,23 @@ public class RadarrClient : ArrClient, IRadarrClient JsonSerializer serializer = JsonSerializer.CreateDefault(); return serializer.Deserialize>(reader) ?? []; } + + public override async Task> 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>(reader) ?? []; + } public async Task> GetQualityProfilesAsync(ArrInstance arrInstance) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index f86cb442..705f5a15 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -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(response); } + + public override async Task> GetAllTagsAsync(ArrInstance arrInstance) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index 9a74b03f..ae7ee02b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -227,6 +227,23 @@ public class SonarrClient : ArrClient, ISonarrClient return serializer.Deserialize>(reader) ?? []; } + public override async Task> 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>(reader) ?? []; + } + public async Task> GetEpisodesAsync(ArrInstance arrInstance, long seriesId) { UriBuilder uriBuilder = new(arrInstance.Url); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs index 07db1f8b..ec2ed4db 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs @@ -277,4 +277,9 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client return commands; } + + public override async Task> GetAllTagsAsync(ArrInstance arrInstance) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs index 58f868b8..b4fda4db 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs @@ -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(response); } + + public override async Task> GetAllTagsAsync(ArrInstance arrInstance) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 9e3c7652..3b4af6be 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -422,8 +422,12 @@ public sealed class Seeker : IHandler HashSet queuedMovieIds) { List movies = await _radarrClient.GetAllMoviesAsync(arrInstance); + List tags = await _radarrClient.GetAllTagsAsync(arrInstance); List allLibraryIds = movies.Select(m => m.Id).ToList(); + Dictionary tagsById = tags.ToDictionary(t => t.Id, t => t.Label); + HashSet skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase); + // Load cached CF scores when custom format score filtering is enabled Dictionary? 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 series = await _sonarrClient.GetAllSeriesAsync(arrInstance); + List tags = await _sonarrClient.GetAllTagsAsync(arrInstance); List allLibraryIds = series.Select(s => s.Id).ToList(); DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours); + Dictionary tagsById = tags.ToDictionary(t => t.Id, t => t.Label); + HashSet 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