FilterV3 backend

This commit is contained in:
Amelia 2026-03-24 19:43:32 +01:00
parent 68be9ab722
commit 7c8e04d3f9
No known key found for this signature in database
GPG Key ID: 44DBD99C9CCFD079
13 changed files with 567 additions and 0 deletions

View File

@ -0,0 +1,13 @@
using System.Threading.Tasks;
using Kavita.Models.DTOs.Filtering.v3;
namespace Kavita.API.Services;
public interface IFilterV3Service
{
Task<FilterResponse> Filter(int userId, FilterV3Dto filter);
FilterConfigurationDto GetConfiguration();
}

View File

@ -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<FilterEntityConfigurationDto> Series { get; set; }
public List<FilterEntityConfigurationDto> Chapters { get; set; }
}
public sealed record FilterEntityConfigurationDto
{
public FilterEntity Entity { get; set; }
public List<FilterFieldConfigurationDto> Fields { get; set; } = [];
}
public sealed record FilterFieldConfigurationDto
{
public FilterFieldV3 Field { get; set; }
public List<FilterComparison> Comparisons { get; set; } = [];
}

View File

@ -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,
}

View File

@ -0,0 +1,10 @@
namespace Kavita.Models.DTOs.Filtering.v3;
public enum FilterFieldV3
{
SeriesName = 0,
AgeRating = 1,
FileSize = 2,
Library = 3,
Progress = 4,
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Kavita.Models.DTOs.Filtering.v3;
public sealed record FilterResponse
{
public List<SeriesDto> Series { get; set; }
public List<ChapterDto> Chapters { get; set; }
}

View File

@ -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<FilterV3GroupDto> Groups { get; set; }
public FilterCombination Combination { get; set; }
public List<FilterEntity> RequestedEntities { get; set; }
}
public sealed record FilterV3GroupDto
{
public FilterCombination Combination { get; set; }
public List<FilterV3StatementDto> 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; }
}

View File

@ -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<ActionResult<FilterResponse>> Filter([FromBody] FilterV3Dto filter)
{
return Ok(await filterV3Service.Filter(UserId, filter));
}
[HttpGet]
public ActionResult<FilterConfigurationDto> GetFilterConfiguration()
{
return Ok(filterV3Service.GetConfiguration());
}
}

View File

@ -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<IFontService, FontService>();
services.AddScoped<IAnnotationService, AnnotationService>();
services.AddScoped<IOpdsService, OpdsService>();
services.AddScoped<IFilterV3Service, FilterV3Service>();
services.AddScoped<ICblExportService, CblExportService>();
services.AddScoped<ICblGithubService, CblGithubService>();

View File

@ -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<Series>
{
public IQueryable<Series> Apply(IQueryable<Series> query, FilterComparison comparison, FilterContext<string> context)
{
if (string.IsNullOrEmpty(context.Value)) return query;
var value = context.Value.AsFloat();
return query.HasReadingProgress(true, comparison, value, context.UserId);
}
public IReadOnlySet<FilterComparison> SupportedComparisons { get; } = new ReadOnlySet<FilterComparison>(new HashSet<FilterComparison>([
FilterComparison.Equal, FilterComparison.NotEqual,
FilterComparison.GreaterThan, FilterComparison.GreaterThanEqual,
FilterComparison.LessThan, FilterComparison.LessThanEqual
]));
}
public class ChapterComparisonField : IFilterField<Chapter>
{
public IQueryable<Chapter> Apply(IQueryable<Chapter> query, FilterComparison comparison, FilterContext<string> 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<FilterComparison> SupportedComparisons { get; } = new ReadOnlySet<FilterComparison>(new HashSet<FilterComparison>([
FilterComparison.Equal, FilterComparison.NotEqual,
FilterComparison.GreaterThan, FilterComparison.GreaterThanEqual,
FilterComparison.LessThan, FilterComparison.LessThanEqual
]));
}

View File

@ -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<TEntity>
{
IQueryable<TEntity> Apply(IQueryable<TEntity> query, FilterV3StatementDto statement, FilterContext<string> context);
IReadOnlyDictionary<FilterFieldV3, IReadOnlySet<FilterComparison>> SupportedComparisons { get; }
}
public class FilterEntityBuilder<TEntity>
{
private readonly Dictionary<FilterFieldV3, IFilterField<TEntity>> _fields = [];
public FilterEntityBuilder<TEntity> WithField(FilterFieldV3 field, IFilterField<TEntity> filterField)
{
if (!_fields.TryAdd(field, filterField))
throw new ArgumentException("Cannot register for the same field twice", nameof(field));
return this;
}
public IFilterEntity<TEntity> Build()
{
return new FilterEntity(_fields);
}
private sealed class FilterEntity(Dictionary<FilterFieldV3, IFilterField<TEntity>> fields): IFilterEntity<TEntity>
{
public IQueryable<TEntity> Apply(IQueryable<TEntity> query, FilterV3StatementDto statement, FilterContext<string> context)
{
if (fields.TryGetValue(statement.Field, out var field))
{
return field.Apply(query, statement.Comparison, context);
}
return query;
}
public IReadOnlyDictionary<FilterFieldV3, IReadOnlySet<FilterComparison>> SupportedComparisons { get; } =
fields.ToDictionary(kv => kv.Key, kv => kv.Value.SupportedComparisons).AsReadOnly();
}
}

View File

@ -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<TEntity>
{
IQueryable<TEntity> Apply(IQueryable<TEntity> query, FilterComparison comparison, FilterContext<string> context);
IReadOnlySet<FilterComparison> SupportedComparisons { get; }
}
public delegate Expression<Func<TEntity, bool>> FilterExpression<TEntity, TValue>(FilterContext<TValue> ctx);
public delegate IQueryable<TEntity> FilterFunc<TEntity, TValue>(FilterContext<TValue> ctx, IQueryable<TEntity> query);
public class FilterFieldBuilder<TEntity, TValue>(Func<string, TValue> convertor)
where TEntity: class
{
private readonly Dictionary<FilterComparison, FilterExpression<TEntity, TValue>> _comparisons = [];
private readonly Dictionary<FilterComparison, FilterFunc<TEntity, TValue>> _funcComparisons = [];
private Func<FilterContext<TValue>, bool>? _guard;
public FilterFieldBuilder<TEntity, TValue> WithGuard(Func<FilterContext<TValue>, bool> guard)
{
_guard = guard;
return this;
}
public FilterFieldBuilder<TEntity, TValue> WithComparison(FilterComparison comparison, FilterExpression<TEntity, TValue> 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<TEntity, TValue> WithComparison(FilterComparison combination, FilterFunc<TEntity, TValue> 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<TEntity> Build()
{
return new FilterField(_comparisons, _funcComparisons, convertor, _guard);
}
private sealed class FilterField(
Dictionary<FilterComparison, FilterExpression<TEntity, TValue>> comparisons,
Dictionary<FilterComparison, FilterFunc<TEntity, TValue>> funcComparisons,
Func<string, TValue> converter,
Func<FilterContext<TValue>, bool>? guard
) : IFilterField<TEntity>
{
public IQueryable<TEntity> Apply(IQueryable<TEntity> query, FilterComparison comparison, FilterContext<string> context)
{
if (comparisons.TryGetValue(comparison, out var expression))
{
var value = converter(context.Value);
var newContext = new FilterContext<TValue> { 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<TValue> { Value = value, UserId = context.UserId };
if (guard != null && !guard(newContext)) return query;
return func(newContext, query);
}
return query;
}
public IReadOnlySet<FilterComparison> SupportedComparisons { get; } = comparisons.Keys
.Union(funcComparisons.Keys).ToHashSet();
}
}
public class StringFilterFieldBuilder<TEntity>() : FilterFieldBuilder<TEntity, string>(s => s)
where TEntity : class;
public class IntArrayFilterFieldBuilder<TEntity>() : FilterFieldBuilder<TEntity, IList<int>>(s => s.ParseIntArray())
where TEntity : class;
public class IntFilterFieldBuilder<TEntity>() : FilterFieldBuilder<TEntity, int>(int.Parse)
where TEntity : class;
public class EnumArrayFilterFieldBuilder<TEntity, TEnum>() : FilterFieldBuilder<TEntity, IList<TEnum>>(s => s
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(Enum.Parse<TEnum>)
.ToList()
)
where TEntity : class
where TEnum : struct;

View File

@ -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<TEntity>
{
IQueryable<TEntity> Apply(IQueryable<TEntity> query, FilterV3Dto filter, int userId);
IReadOnlyDictionary<FilterEntity, IReadOnlyDictionary<FilterFieldV3, IReadOnlySet<FilterComparison>>> SupportedEntities { get; }
List<FilterEntityConfigurationDto> Configuration();
}
public class FilterContext<TValue>
{
public required int UserId { get; init; }
public required TValue Value { get; init; }
}
public class FilterPipelineBuilder<TEntity>
{
private readonly Dictionary<FilterEntity, IFilterEntity<TEntity>> _entities = [];
public FilterPipelineBuilder<TEntity> WithEntity(FilterEntity entity, IFilterEntity<TEntity> filterEntity)
{
if (!_entities.TryAdd(entity, filterEntity))
throw new ArgumentException("Cannot register the same entity twice", nameof(entity));
return this;
}
public IFilterPipeline<TEntity> Build()
{
return new FilterPipeline(_entities);
}
private sealed class FilterPipeline(Dictionary<FilterEntity, IFilterEntity<TEntity>> entities): IFilterPipeline<TEntity>
{
public IQueryable<TEntity> Apply(IQueryable<TEntity> 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<FilterEntity, IReadOnlyDictionary<FilterFieldV3, IReadOnlySet<FilterComparison>>> SupportedEntities { get; } =
entities.ToDictionary(kv => kv.Key, kv => kv.Value.SupportedComparisons).AsReadOnly();
public List<FilterEntityConfigurationDto> 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<TEntity> ApplyGroup(IQueryable<TEntity> query, FilterV3GroupDto group, int userId)
{
var statements = group.Statements
.Select(statement => ApplyEntity(query, statement, new FilterContext<string> { 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<TEntity> ApplyEntity(IQueryable<TEntity> query, FilterV3StatementDto statement, FilterContext<string> context)
{
if (entities.TryGetValue(statement.Entity, out var entity))
{
return entity.Apply(query, statement, context);
}
return query;
}
}
}

View File

@ -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<Series> SeriesFilterPipeline = new FilterPipelineBuilder<Series>()
.WithEntity(FilterEntity.Series, new FilterEntityBuilder<Series>()
.WithField(FilterFieldV3.AgeRating, new EnumArrayFilterFieldBuilder<Series, AgeRating>()
.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<Series>()
.WithGuard(ctx => !string.IsNullOrWhiteSpace(ctx.Value))
.WithComparison(FilterComparison.Contains, ctx => s => EF.Functions.Like(s.Name, $"%{ctx.Value}%"))
.Build())
.WithField(FilterFieldV3.Library, new IntArrayFilterFieldBuilder<Series>()
.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<Series>()
.WithField(FilterFieldV3.FileSize, new FilterFieldBuilder<Series, long>(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<Chapter> ChapterFilterPipeline = new FilterPipelineBuilder<Chapter>()
.WithEntity(FilterEntity.Chapters, new FilterEntityBuilder<Chapter>()
.WithField(FilterFieldV3.FileSize, new FilterFieldBuilder<Chapter, long>(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<Chapter>()
.WithField(FilterFieldV3.SeriesName, new StringFilterFieldBuilder<Chapter>()
.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<FilterResponse> Filter(int userId, FilterV3Dto filter)
{
var seriesTask = ApplyFilterWithProgress<Series, SeriesDto>(FilterEntity.Series, SeriesFilterPipeline, filter, userId);
var chapterTask = ApplyFilterWithProgress<Chapter, ChapterDto>(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<List<TEntityDto>> ApplyFilterWithProgress<TEntity, TEntityDto>(FilterEntity entity,
IFilterPipeline<TEntity> filterPipeline, FilterV3Dto filter, int userId)
where TEntity : class
{
if (!filter.RequestedEntities.Contains(entity))
return Task.FromResult<List<TEntityDto>>([]);
return filterPipeline.Apply(dataContext.Set<TEntity>().AsNoTracking(), filter, userId)
.ProjectToWithProgress<TEntity, TEntityDto>(mapper, userId)
.ToListAsync();
}
}