diff --git a/Kavita.API/Services/IFilterV3Service.cs b/Kavita.API/Services/IFilterV3Service.cs new file mode 100644 index 000000000..4313b94ce --- /dev/null +++ b/Kavita.API/Services/IFilterV3Service.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using Kavita.Models.DTOs.Filtering.v3; + +namespace Kavita.API.Services; + +public interface IFilterV3Service +{ + + Task Filter(int userId, FilterV3Dto filter); + + FilterConfigurationDto GetConfiguration(); + +} diff --git a/Kavita.Models/DTOs/Filtering/v3/FilterConfigurationDto.cs b/Kavita.Models/DTOs/Filtering/v3/FilterConfigurationDto.cs new file mode 100644 index 000000000..04fe61141 --- /dev/null +++ b/Kavita.Models/DTOs/Filtering/v3/FilterConfigurationDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Kavita.Models.DTOs.Filtering.v2; + +namespace Kavita.Models.DTOs.Filtering.v3; + +public sealed record FilterConfigurationDto +{ + + public List Series { get; set; } + public List Chapters { get; set; } + +} + +public sealed record FilterEntityConfigurationDto +{ + public FilterEntity Entity { get; set; } + public List Fields { get; set; } = []; +} + +public sealed record FilterFieldConfigurationDto +{ + public FilterFieldV3 Field { get; set; } + public List Comparisons { get; set; } = []; +} diff --git a/Kavita.Models/DTOs/Filtering/v3/FilterEntity.cs b/Kavita.Models/DTOs/Filtering/v3/FilterEntity.cs new file mode 100644 index 000000000..9cfc4b510 --- /dev/null +++ b/Kavita.Models/DTOs/Filtering/v3/FilterEntity.cs @@ -0,0 +1,13 @@ +namespace Kavita.Models.DTOs.Filtering.v3; + +public enum FilterEntity +{ + Series = 0, + Chapters = 1, + Volumes = 2, + Annotations = 3, + People = 4, + ReadingLists = 5, + Collections = 6, + Reviews = 7, +} diff --git a/Kavita.Models/DTOs/Filtering/v3/FilterFieldV3.cs b/Kavita.Models/DTOs/Filtering/v3/FilterFieldV3.cs new file mode 100644 index 000000000..ed01c81ab --- /dev/null +++ b/Kavita.Models/DTOs/Filtering/v3/FilterFieldV3.cs @@ -0,0 +1,10 @@ +namespace Kavita.Models.DTOs.Filtering.v3; + +public enum FilterFieldV3 +{ + SeriesName = 0, + AgeRating = 1, + FileSize = 2, + Library = 3, + Progress = 4, +} diff --git a/Kavita.Models/DTOs/Filtering/v3/FilterResponse.cs b/Kavita.Models/DTOs/Filtering/v3/FilterResponse.cs new file mode 100644 index 000000000..f93971e22 --- /dev/null +++ b/Kavita.Models/DTOs/Filtering/v3/FilterResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Kavita.Models.DTOs.Filtering.v3; + +public sealed record FilterResponse +{ + + public List Series { get; set; } + public List Chapters { get; set; } + +} diff --git a/Kavita.Models/DTOs/Filtering/v3/FilterV3Dto.cs b/Kavita.Models/DTOs/Filtering/v3/FilterV3Dto.cs new file mode 100644 index 000000000..fe2eb64f4 --- /dev/null +++ b/Kavita.Models/DTOs/Filtering/v3/FilterV3Dto.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Kavita.Models.DTOs.Filtering.v2; + +namespace Kavita.Models.DTOs.Filtering.v3; + +public sealed record FilterV3Dto +{ + + public List Groups { get; set; } + public FilterCombination Combination { get; set; } + public List RequestedEntities { get; set; } +} + +public sealed record FilterV3GroupDto +{ + public FilterCombination Combination { get; set; } + public List Statements { get; set; } +} + +public sealed record FilterV3StatementDto +{ + public FilterEntity Entity { get; set; } + public FilterComparison Comparison { get; set; } + public FilterFieldV3 Field { get; set; } + public string Value { get; set; } +} diff --git a/Kavita.Server/Controllers/FilterV3Controller.cs b/Kavita.Server/Controllers/FilterV3Controller.cs new file mode 100644 index 000000000..93f30c9ef --- /dev/null +++ b/Kavita.Server/Controllers/FilterV3Controller.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using AutoMapper; +using Kavita.API.Database; +using Kavita.API.Services; +using Kavita.Models.DTOs.Filtering.v3; +using Microsoft.AspNetCore.Mvc; + +namespace Kavita.Server.Controllers; + +public class FilterV3Controller(IFilterV3Service filterV3Service, IDataContext dataContext, IMapper mapper): BaseApiController +{ + + [HttpPost] + public async Task> Filter([FromBody] FilterV3Dto filter) + { + return Ok(await filterV3Service.Filter(UserId, filter)); + } + + [HttpGet] + public ActionResult GetFilterConfiguration() + { + return Ok(filterV3Service.GetConfiguration()); + } + +} diff --git a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs index fbebf8f3f..5eddc0542 100644 --- a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs +++ b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs @@ -7,6 +7,7 @@ using Kavita.API.Services.Reading; using Kavita.API.Services.ReadingLists; using Kavita.API.Services.Scanner; using Kavita.API.Services.SignalR; +using Kavita.Services.Filtering; using Kavita.Services.Helpers; using Kavita.Services.HostedServices; using Kavita.Services.Metadata; @@ -57,6 +58,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Kavita.Services/Filtering/ComparisonField.cs b/Kavita.Services/Filtering/ComparisonField.cs new file mode 100644 index 000000000..7f248fbbf --- /dev/null +++ b/Kavita.Services/Filtering/ComparisonField.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Kavita.Common.Extensions; +using Kavita.Database.Extensions; +using Kavita.Database.Extensions.Filters; +using Kavita.Models.DTOs.Filtering.v2; +using Kavita.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Kavita.Services.Filtering; + +public class SeriesComparisonField : IFilterField +{ + public IQueryable Apply(IQueryable query, FilterComparison comparison, FilterContext context) + { + if (string.IsNullOrEmpty(context.Value)) return query; + + var value = context.Value.AsFloat(); + + return query.HasReadingProgress(true, comparison, value, context.UserId); + } + + public IReadOnlySet SupportedComparisons { get; } = new ReadOnlySet(new HashSet([ + FilterComparison.Equal, FilterComparison.NotEqual, + FilterComparison.GreaterThan, FilterComparison.GreaterThanEqual, + FilterComparison.LessThan, FilterComparison.LessThanEqual + ])); +} + +public class ChapterComparisonField : IFilterField +{ + public IQueryable Apply(IQueryable query, FilterComparison comparison, FilterContext context) + { + if (string.IsNullOrEmpty(context.Value)) return query; + + var readProgress = context.Value.AsFloat(); + + var subQuery = query + .Select(s => new + { + ChapterId = s.Id, + Percentage = s.UserProgress + .Where(p => p != null && p.AppUserId == context.UserId) + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f + }) + .AsSplitQuery(); + + switch (comparison) + { + case FilterComparison.Equal: + subQuery = subQuery.WhereEqual(s => s.Percentage, readProgress); + break; + case FilterComparison.GreaterThan: + subQuery = subQuery.WhereGreaterThan(s => s.Percentage, readProgress); + break; + case FilterComparison.GreaterThanEqual: + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.Percentage, readProgress); + break; + case FilterComparison.LessThan: + subQuery = subQuery.WhereLessThan(s => s.Percentage, readProgress); + break; + case FilterComparison.LessThanEqual: + subQuery = subQuery.WhereLessThanOrEqual(s => s.Percentage, readProgress); + break; + case FilterComparison.NotEqual: + subQuery = subQuery.WhereNotEqual(s => s.Percentage, readProgress); + break; + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + + var ids = subQuery.Select(s => s.ChapterId); + return query.Where(c => ids.Contains(c.Id)); + } + + public IReadOnlySet SupportedComparisons { get; } = new ReadOnlySet(new HashSet([ + FilterComparison.Equal, FilterComparison.NotEqual, + FilterComparison.GreaterThan, FilterComparison.GreaterThanEqual, + FilterComparison.LessThan, FilterComparison.LessThanEqual + ])); +} diff --git a/Kavita.Services/Filtering/FilterEntityBuilder.cs b/Kavita.Services/Filtering/FilterEntityBuilder.cs new file mode 100644 index 000000000..09d6c876b --- /dev/null +++ b/Kavita.Services/Filtering/FilterEntityBuilder.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Kavita.Models.DTOs.Filtering.v2; +using Kavita.Models.DTOs.Filtering.v3; + +namespace Kavita.Services.Filtering; + +public interface IFilterEntity +{ + IQueryable Apply(IQueryable query, FilterV3StatementDto statement, FilterContext context); + + IReadOnlyDictionary> SupportedComparisons { get; } +} + +public class FilterEntityBuilder +{ + + private readonly Dictionary> _fields = []; + + public FilterEntityBuilder WithField(FilterFieldV3 field, IFilterField filterField) + { + if (!_fields.TryAdd(field, filterField)) + throw new ArgumentException("Cannot register for the same field twice", nameof(field)); + + return this; + } + + public IFilterEntity Build() + { + return new FilterEntity(_fields); + } + + private sealed class FilterEntity(Dictionary> fields): IFilterEntity + { + public IQueryable Apply(IQueryable query, FilterV3StatementDto statement, FilterContext context) + { + if (fields.TryGetValue(statement.Field, out var field)) + { + return field.Apply(query, statement.Comparison, context); + } + + return query; + } + + public IReadOnlyDictionary> SupportedComparisons { get; } = + fields.ToDictionary(kv => kv.Key, kv => kv.Value.SupportedComparisons).AsReadOnly(); + } + +} diff --git a/Kavita.Services/Filtering/FilterFieldBuilder.cs b/Kavita.Services/Filtering/FilterFieldBuilder.cs new file mode 100644 index 000000000..3582ec06a --- /dev/null +++ b/Kavita.Services/Filtering/FilterFieldBuilder.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Kavita.Common.Extensions; +using Kavita.Models.DTOs.Filtering.v2; + +namespace Kavita.Services.Filtering; + +public interface IFilterField +{ + IQueryable Apply(IQueryable query, FilterComparison comparison, FilterContext context); + IReadOnlySet SupportedComparisons { get; } +} + +public delegate Expression> FilterExpression(FilterContext ctx); +public delegate IQueryable FilterFunc(FilterContext ctx, IQueryable query); + +public class FilterFieldBuilder(Func convertor) +where TEntity: class +{ + private readonly Dictionary> _comparisons = []; + private readonly Dictionary> _funcComparisons = []; + private Func, bool>? _guard; + + public FilterFieldBuilder WithGuard(Func, bool> guard) + { + _guard = guard; + return this; + } + + public FilterFieldBuilder WithComparison(FilterComparison comparison, FilterExpression expression) + { + if (_funcComparisons.ContainsKey(comparison)) + throw new ArgumentException("Cannot register the same comparison twice", nameof(comparison)); + + if (!_comparisons.TryAdd(comparison, expression)) + throw new ArgumentException("Cannot register the same comparison twice", nameof(comparison)); + + return this; + } + + public FilterFieldBuilder WithComparison(FilterComparison combination, FilterFunc func) + { + if (_comparisons.ContainsKey(combination)) + throw new ArgumentException("Cannot register the same combination twice", nameof(combination)); + + if (!_funcComparisons.TryAdd(combination, func)) + throw new ArgumentException("Cannot register the same combination twice", nameof(combination)); + + return this; + } + + public IFilterField Build() + { + return new FilterField(_comparisons, _funcComparisons, convertor, _guard); + } + + private sealed class FilterField( + Dictionary> comparisons, + Dictionary> funcComparisons, + Func converter, + Func, bool>? guard + ) : IFilterField + { + public IQueryable Apply(IQueryable query, FilterComparison comparison, FilterContext context) + { + if (comparisons.TryGetValue(comparison, out var expression)) + { + var value = converter(context.Value); + var newContext = new FilterContext { Value = value, UserId = context.UserId }; + if (guard != null && !guard(newContext)) return query; + + return query.Where(expression(newContext)); + } + + if (funcComparisons.TryGetValue(comparison, out var func)) + { + var value = converter(context.Value); + var newContext = new FilterContext { Value = value, UserId = context.UserId }; + if (guard != null && !guard(newContext)) return query; + + return func(newContext, query); + } + + return query; + } + + public IReadOnlySet SupportedComparisons { get; } = comparisons.Keys + .Union(funcComparisons.Keys).ToHashSet(); + } + +} + +public class StringFilterFieldBuilder() : FilterFieldBuilder(s => s) + where TEntity : class; + +public class IntArrayFilterFieldBuilder() : FilterFieldBuilder>(s => s.ParseIntArray()) + where TEntity : class; + +public class IntFilterFieldBuilder() : FilterFieldBuilder(int.Parse) + where TEntity : class; + +public class EnumArrayFilterFieldBuilder() : FilterFieldBuilder>(s => s + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(Enum.Parse) + .ToList() +) + where TEntity : class + where TEnum : struct; diff --git a/Kavita.Services/Filtering/FilterPipelineBuilder.cs b/Kavita.Services/Filtering/FilterPipelineBuilder.cs new file mode 100644 index 000000000..5e938c77f --- /dev/null +++ b/Kavita.Services/Filtering/FilterPipelineBuilder.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Kavita.Models.DTOs.Filtering.v2; +using Kavita.Models.DTOs.Filtering.v3; + +namespace Kavita.Services.Filtering; + +public interface IFilterPipeline +{ + IQueryable Apply(IQueryable query, FilterV3Dto filter, int userId); + + IReadOnlyDictionary>> SupportedEntities { get; } + + List Configuration(); +} + +public class FilterContext +{ + public required int UserId { get; init; } + public required TValue Value { get; init; } +} + +public class FilterPipelineBuilder +{ + + private readonly Dictionary> _entities = []; + + public FilterPipelineBuilder WithEntity(FilterEntity entity, IFilterEntity filterEntity) + { + if (!_entities.TryAdd(entity, filterEntity)) + throw new ArgumentException("Cannot register the same entity twice", nameof(entity)); + + return this; + } + + public IFilterPipeline Build() + { + return new FilterPipeline(_entities); + } + + private sealed class FilterPipeline(Dictionary> entities): IFilterPipeline + { + public IQueryable Apply(IQueryable query, FilterV3Dto filter, int userId) + { + var groups = filter.Groups + .Select(group => ApplyGroup(query, group, userId)) + .ToList(); + + if (groups.Count == 0) return query; // Return everything if no filters + + return filter.Combination == FilterCombination.And + ? groups.Aggregate((current, next) => current.Intersect(next)) + : groups.Aggregate((current, next) => current.Union(next)); + } + + public IReadOnlyDictionary>> SupportedEntities { get; } = + entities.ToDictionary(kv => kv.Key, kv => kv.Value.SupportedComparisons).AsReadOnly(); + + public List Configuration() + { + return SupportedEntities.Select(kv => new FilterEntityConfigurationDto() + { + Entity = kv.Key, + Fields = kv.Value.Select(kv => new FilterFieldConfigurationDto() + { + Field = kv.Key, + Comparisons = kv.Value.ToList() + }).ToList() + }).ToList(); + } + + private IQueryable ApplyGroup(IQueryable query, FilterV3GroupDto group, int userId) + { + var statements = group.Statements + .Select(statement => ApplyEntity(query, statement, new FilterContext { UserId = userId, Value = statement.Value })) + .ToList(); + + if (statements.Count == 0) return query; + + return group.Combination == FilterCombination.And + ? statements.Aggregate((current, next) => current.Intersect(next)) + : statements.Aggregate((current, next) => current.Union(next)); + } + + + private IQueryable ApplyEntity(IQueryable query, FilterV3StatementDto statement, FilterContext context) + { + if (entities.TryGetValue(statement.Entity, out var entity)) + { + return entity.Apply(query, statement, context); + } + + return query; + } + + } +} diff --git a/Kavita.Services/Filtering/FilterV3Service.cs b/Kavita.Services/Filtering/FilterV3Service.cs new file mode 100644 index 000000000..a25a45f5f --- /dev/null +++ b/Kavita.Services/Filtering/FilterV3Service.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using Kavita.API.Database; +using Kavita.API.Services; +using Kavita.Common.Extensions; +using Kavita.Database; +using Kavita.Database.Extensions; +using Kavita.Models.DTOs; +using Kavita.Models.DTOs.Filtering.v2; +using Kavita.Models.DTOs.Filtering.v3; +using Kavita.Models.Entities; +using Kavita.Models.Entities.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Kavita.Services.Filtering; + +public class FilterV3Service(DataContext dataContext, IMapper mapper): IFilterV3Service +{ + + private static readonly IFilterPipeline SeriesFilterPipeline = new FilterPipelineBuilder() + .WithEntity(FilterEntity.Series, new FilterEntityBuilder() + .WithField(FilterFieldV3.AgeRating, new EnumArrayFilterFieldBuilder() + .WithGuard(ctx => ctx.Value.Count > 0) + .WithComparison(FilterComparison.LessThan, ctx => s => s.Metadata.AgeRating < ctx.Value[0]) + .WithComparison(FilterComparison.LessThanEqual, ctx => s => s.Metadata.AgeRating <= ctx.Value[0]) + .WithComparison(FilterComparison.Equal, ctx => s => s.Metadata.AgeRating == ctx.Value[0]) + .WithComparison(FilterComparison.GreaterThanEqual, ctx => s => s.Metadata.AgeRating > ctx.Value[0]) + .WithComparison(FilterComparison.GreaterThan, ctx => s => s.Metadata.AgeRating <= ctx.Value[0]) + .WithComparison(FilterComparison.Contains, ctx => s => ctx.Value.Contains(s.Metadata.AgeRating)) + .Build()) + .WithField(FilterFieldV3.SeriesName, new StringFilterFieldBuilder() + .WithGuard(ctx => !string.IsNullOrWhiteSpace(ctx.Value)) + .WithComparison(FilterComparison.Contains, ctx => s => EF.Functions.Like(s.Name, $"%{ctx.Value}%")) + .Build()) + .WithField(FilterFieldV3.Library, new IntArrayFilterFieldBuilder() + .WithGuard(ctx => ctx.Value.Count > 0) + .WithComparison(FilterComparison.Contains, ctx => s => ctx.Value.Contains(s.LibraryId)) + .Build()) + .WithField(FilterFieldV3.Progress, new SeriesComparisonField()) + .Build()) + .WithEntity(FilterEntity.Chapters, new FilterEntityBuilder() + .WithField(FilterFieldV3.FileSize, new FilterFieldBuilder(s => s.ParseHumanReadableBytes()) + .WithGuard(ctx => ctx.Value > 0) + .WithComparison(FilterComparison.GreaterThan, ctx => s => s.Volumes + .Any(v => v.Chapters.Any(c => c.Files.Sum(f => f.Bytes) > ctx.Value))) + .Build()) + .Build()) + .Build(); + + private static readonly IFilterPipeline ChapterFilterPipeline = new FilterPipelineBuilder() + .WithEntity(FilterEntity.Chapters, new FilterEntityBuilder() + .WithField(FilterFieldV3.FileSize, new FilterFieldBuilder(s => s.ParseHumanReadableBytes()) + .WithComparison(FilterComparison.GreaterThan, ctx => s => s.Files.Sum(f => f.Bytes) > ctx.Value) + .Build()) + .WithField(FilterFieldV3.Progress, new ChapterComparisonField()) + .Build()) + .WithEntity(FilterEntity.Series, new FilterEntityBuilder() + .WithField(FilterFieldV3.SeriesName, new StringFilterFieldBuilder() + .WithGuard(ctx => !string.IsNullOrWhiteSpace(ctx.Value)) + .WithComparison(FilterComparison.Contains, ctx => c => EF.Functions.Like(c.Volume.Series.Name, $"%{ctx.Value}%")) + .Build()) + .Build()) + .Build(); + + public async Task Filter(int userId, FilterV3Dto filter) + { + var seriesTask = ApplyFilterWithProgress(FilterEntity.Series, SeriesFilterPipeline, filter, userId); + var chapterTask = ApplyFilterWithProgress(FilterEntity.Chapters, ChapterFilterPipeline, filter, userId); + + await Task.WhenAll(seriesTask, chapterTask); + + return new FilterResponse + { + Series = await seriesTask, + Chapters = await chapterTask + }; + } + + public FilterConfigurationDto GetConfiguration() + { + return new FilterConfigurationDto + { + Series = SeriesFilterPipeline.Configuration(), + Chapters = ChapterFilterPipeline.Configuration() + }; + } + + private Task> ApplyFilterWithProgress(FilterEntity entity, + IFilterPipeline filterPipeline, FilterV3Dto filter, int userId) + where TEntity : class + { + if (!filter.RequestedEntities.Contains(entity)) + return Task.FromResult>([]); + + return filterPipeline.Apply(dataContext.Set().AsNoTracking(), filter, userId) + .ProjectToWithProgress(mapper, userId) + .ToListAsync(); + } +}