Updated search logic. More UI updates.
All checks were successful
ci / build-test (push) Successful in 2m17s
ci / publish-image (push) Has been skipped

This commit is contained in:
2025-11-17 21:05:55 -05:00
parent 9ef1972472
commit 2418bd0a8f
13 changed files with 430 additions and 108 deletions

View File

@@ -8,6 +8,8 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
where TCriteria : new()
where TSortField : struct, Enum
{
protected abstract bool UseSelectIdQuery { get; }
public async Task<SearchResult<TItem>> SearchAsync(SearchOptions<TCriteria, TSortField> options, CancellationToken cancellationToken = default)
{
IQueryable<TBaseQuery> baseQuery = GetBaseQuery();
@@ -16,12 +18,7 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
int total = await filteredQuery.CountAsync(cancellationToken);
IOrderedQueryable<TBaseQuery> orderedQuery = ApplySorting(filteredQuery, options.SortOptions);
IQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery);
TItem[] items = await selectQuery
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
TItem[] items = await GetItemsAsync(options, orderedQuery, cancellationToken);
await PostLoadAsync(items, cancellationToken);
@@ -44,10 +41,10 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
var (field, direction) = (sortOptions[i].Field, sortOptions[i].Direction);
bool isDescending = direction == SortDirection.Descending;
IOrderedQueryable<TBaseQuery> applyFirst(Expression<Func<TBaseQuery, object>> selector) => isDescending ? query.OrderByDescending(selector) : query.OrderBy(selector);
IOrderedQueryable<TBaseQuery> applyNext(Expression<Func<TBaseQuery, object>> selector) => isDescending ? ordered!.ThenByDescending(selector) : ordered!.ThenBy(selector);
IOrderedQueryable<TBaseQuery> applyFirst(Expression<Func<TBaseQuery, object?>> selector) => isDescending ? query.OrderByDescending(selector) : query.OrderBy(selector);
IOrderedQueryable<TBaseQuery> applyNext(Expression<Func<TBaseQuery, object?>> selector) => isDescending ? ordered!.ThenByDescending(selector) : ordered!.ThenBy(selector);
Expression<Func<TBaseQuery, object>> selector = GetSortExpression(field);
Expression<Func<TBaseQuery, object?>> selector = GetSortExpression(field);
ordered = (i == 0) ? applyFirst(selector) : applyNext(selector);
}
@@ -83,8 +80,34 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
}
}
protected abstract Expression<Func<TBaseQuery, object>> GetSortExpression(TSortField field);
private async Task<TItem[]> GetItemsAsync(SearchOptions<TCriteria, TSortField> options, IOrderedQueryable<TBaseQuery> orderedQuery, CancellationToken cancellationToken)
{
if (UseSelectIdQuery)
{
int[] ids = await GetSelectIdQuery(orderedQuery)
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
Dictionary<int, TItem> items = await GetItems(ids);
return [.. ids.Select(uniqueId => items[uniqueId])];
}
else
{
IQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery);
return await selectQuery
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
}
}
protected abstract Expression<Func<TBaseQuery, object?>> GetSortExpression(TSortField field);
protected abstract IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain();
protected abstract IQueryable<int> GetSelectIdQuery(IOrderedQueryable<TBaseQuery> query);
protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query);
protected abstract Task<Dictionary<int, TItem>> GetItems(int[] ids);
protected virtual Task PostLoadAsync(IList<TItem> items, CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -14,6 +14,8 @@ public class CircleQuery
public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleSearchItem, CircleSearchCriteria, CircleSortField, CircleQuery>, ICircleSearchProvider
{
protected override bool UseSelectIdQuery => false;
protected override IQueryable<CircleQuery> GetBaseQuery()
{
// Project from Circles so we can use correlated subqueries per CircleId.
@@ -120,7 +122,7 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
return query;
}
protected override Expression<Func<CircleQuery, object>> GetSortExpression(CircleSortField field) => field switch
protected override Expression<Func<CircleQuery, object?>> GetSortExpression(CircleSortField field) => field switch
{
CircleSortField.Favorite => x => !x.Circle.Favorite,
CircleSortField.Blacklisted => x => !x.Circle.Blacklisted,
@@ -134,6 +136,11 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
yield return (x => x.Circle.MakerId, SortDirection.Ascending);
}
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<CircleQuery> query)
{
return query.Select(x => x.Circle.CircleId);
}
protected override IQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleQuery> query)
{
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
@@ -205,4 +212,77 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
return selected;
}
protected override async Task<Dictionary<int, CircleSearchItem>> GetItems(int[] ids)
{
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
var selected =
from circle in context.Circles.AsNoTracking()
//join vw in context.VoiceWorks.AsNoTracking() on item.LatestProductId equals vw.ProductId into vws
//from latest in vws.DefaultIfEmpty()
where ids.Contains(circle.CircleId)
select new CircleSearchItem
{
CircleId = circle.CircleId,
Name = circle.Name,
MakerId = circle.MakerId,
Favorite = circle.Favorite,
Blacklisted = circle.Blacklisted,
Spam = circle.Spam,
// Aggregates
Downloads = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => (int?)v.Downloads) // make nullable for Sum over empty set
.Sum() ?? 0,
Releases = context.VoiceWorks
.Count(v => v.CircleId == circle.CircleId && v.SalesDate != null),
Pending = context.VoiceWorks
.Count(v => v.CircleId == circle.CircleId && v.ExpectedDate != null),
FirstReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => v.SalesDate)
.Min(),
LatestReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => v.SalesDate)
.Max(),
// "Latest" by ProductId length, then value
LatestProductId = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.ProductId)
.FirstOrDefault(),
// If you want these two in base query too:
LatestVoiceWorkHasImage = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => (bool?)v.HasImage)
.FirstOrDefault(),
LatestVoiceWorkSalesDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.SalesDate)
.FirstOrDefault(),
//Downloads = item.Downloads,
//Releases = item.Releases,
//Pending = item.Pending,
//FirstReleaseDate = item.FirstReleaseDate,
//LatestReleaseDate = item.LatestReleaseDate,
//LatestProductId = item.LatestProductId,
//LatestVoiceWorkHasImage = latest != null ? latest.HasImage : (bool?)null,
//LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
};
return await selected.ToDictionaryAsync(x => x.CircleId);
}
}

View File

@@ -8,6 +8,8 @@ namespace JSMR.Infrastructure.Data.Repositories.Creators;
public class CreatorSearchProvider(AppDbContext context) : SearchProvider<CreatorSearchItem, CreatorSearchCriteria, CreatorSortField, CreatorSearchItem>, ICreatorSearchProvider
{
protected override bool UseSelectIdQuery => false;
protected override IQueryable<CreatorSearchItem> GetBaseQuery()
{
return
@@ -37,9 +39,9 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
return filteredQuery;
}
protected override Expression<Func<CreatorSearchItem, object>> GetSortExpression(CreatorSortField field)
protected override Expression<Func<CreatorSearchItem, object?>> GetSortExpression(CreatorSortField field)
{
Expression<Func<CreatorSearchItem, object>> selector = field switch
Expression<Func<CreatorSearchItem, object?>> selector = field switch
{
CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount,
CreatorSortField.Favorite => x => !x.Favorite,
@@ -59,4 +61,14 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
{
return query;
}
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<CreatorSearchItem> query)
{
throw new NotImplementedException();
}
protected override Task<Dictionary<int, CreatorSearchItem>> GetItems(int[] ids)
{
return Task.FromResult(new Dictionary<int, CreatorSearchItem>());
}
}

View File

@@ -8,6 +8,8 @@ namespace JSMR.Infrastructure.Data.Repositories.Tags;
public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchItem, TagSearchCriteria, TagSortField, TagSearchItem>, ITagSearchProvider
{
protected override bool UseSelectIdQuery => false;
protected override IQueryable<TagSearchItem> GetBaseQuery()
{
return
@@ -41,9 +43,9 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
return filteredQuery;
}
protected override Expression<Func<TagSearchItem, object>> GetSortExpression(TagSortField field)
protected override Expression<Func<TagSearchItem, object?>> GetSortExpression(TagSortField field)
{
Expression<Func<TagSearchItem, object>> selector = field switch
Expression<Func<TagSearchItem, object?>> selector = field switch
{
TagSortField.EnglishName => x => x.EnglishName ?? "",
TagSortField.VoiceWorkCount => x => x.VoiceWorkCount,
@@ -64,4 +66,14 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
{
return query;
}
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<TagSearchItem> query)
{
throw new NotImplementedException();
}
protected override Task<Dictionary<int, TagSearchItem>> GetItems(int[] ids)
{
return Task.FromResult(new Dictionary<int, TagSearchItem>());
}
}

View File

@@ -5,6 +5,7 @@ using JSMR.Domain.Enums;
using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
using CircleStatus = JSMR.Application.VoiceWorks.Queries.Search.CircleStatus;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
@@ -20,6 +21,8 @@ public class VoiceWorkQuery
public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSearch fullTextSearch) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider
{
protected override bool UseSelectIdQuery => true;
protected override IQueryable<VoiceWorkQuery> GetBaseQuery()
{
return
@@ -45,14 +48,6 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
IQueryable<VoiceWorkQuery> filteredQuery = query;
filteredQuery = ApplyKeywordsFilter(filteredQuery, criteria);
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
if (criteria.Status is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Status == (byte)criteria.Status);
switch (criteria.SaleStatus)
{
@@ -64,6 +59,18 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
break;
}
if (criteria.SupportedLanguages.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
if (criteria.Status is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Status == (byte)criteria.Status);
if (criteria.ScheduledReleaseDateStart is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.PlannedReleaseDate >= criteria.ScheduledReleaseDateStart.Value.ToDateTime(TimeOnly.MinValue));
@@ -79,8 +86,8 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
if (criteria.AgeRatings.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.AgeRatings.Contains((AgeRating)x.VoiceWork.Rating));
if (criteria.SupportedLanguages.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
//if (criteria.SupportedLanguages.Length > 0)
// filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
if (criteria.AIGenerationOptions.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.Contains((AIGeneration)x.VoiceWork.AIGeneration));
@@ -133,6 +140,19 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
break;
}
//switch (criteria.CircleStatus)
//{
// case CircleStatus.NotBlacklisted:
// query = query.Where(x => x.Circle.Blacklisted == false);
// break;
// case CircleStatus.Favorited:
// query = query.Where(x => x.Circle.Favorite);
// break;
// case CircleStatus.Blacklisted:
// query = query.Where(x => x.Circle.Blacklisted);
// break;
//}
return query;
}
@@ -314,13 +334,15 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
protected override IEnumerable<(Expression<Func<VoiceWorkQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{
yield return (x => x.VoiceWork.ProductId, SortDirection.Ascending);
yield return (x => x.VoiceWork.ProductId, SortDirection.Descending);
}
protected override Expression<Func<VoiceWorkQuery, object>> GetSortExpression(VoiceWorkSortField field) => field switch
protected override Expression<Func<VoiceWorkQuery, object?>> GetSortExpression(VoiceWorkSortField field) => field switch
{
VoiceWorkSortField.ScheduledReleaseDate => x => x.VoiceWork.PlannedReleaseDate ?? x.VoiceWork.PlannedReleaseDate ?? DateTime.MinValue,
VoiceWorkSortField.ReleaseDate => x => x.VoiceWork.SalesDate ?? x.VoiceWork.ExpectedDate ?? DateTime.MinValue,
VoiceWorkSortField.ExpectedReleaseDate => x => x.VoiceWork.ExpectedDate,
VoiceWorkSortField.ScheduledReleaseDate => x => x.VoiceWork.PlannedReleaseDate,
VoiceWorkSortField.ReleaseDate => x => x.VoiceWork.SalesDate,
VoiceWorkSortField.AnyReleaseDate => x => x.VoiceWork.SalesDate ?? x.VoiceWork.PlannedReleaseDate ?? x.VoiceWork.ExpectedDate,
VoiceWorkSortField.Downloads => x => x.VoiceWork.Downloads ?? 0,
VoiceWorkSortField.WishlistCount => x => x.VoiceWork.WishlistCount ?? 0,
VoiceWorkSortField.StarRating => x => x.VoiceWork.StarRating ?? 0,
@@ -328,6 +350,11 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
_ => x => x.VoiceWork.ProductId
};
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<VoiceWorkQuery> query)
{
return query.Select(x => x.VoiceWork.VoiceWorkId);
}
protected override IQueryable<VoiceWorkSearchResult> GetSelectQuery(IOrderedQueryable<VoiceWorkQuery> query)
{
var result =
@@ -363,6 +390,43 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
return result;
}
protected override async Task<Dictionary<int, VoiceWorkSearchResult>> GetItems(int[] ids)
{
var result =
from voiceWork in context.VoiceWorks.AsNoTracking()
join englishVoiceWork in context.EnglishVoiceWorks.AsNoTracking() on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
from englishVoiceWork in ps.DefaultIfEmpty()
join circle in context.Circles.AsNoTracking() on voiceWork.CircleId equals circle.CircleId into cs
from circle in cs.DefaultIfEmpty()
let productLinkPage = voiceWork.SalesDate.HasValue ? "work" : "announce"
where ids.Contains(voiceWork.VoiceWorkId)
select new VoiceWorkSearchResult()
{
VoiceWorkId = voiceWork.VoiceWorkId,
ProductId = voiceWork.ProductId,
OriginalProductId = voiceWork.OriginalProductId,
ProductName = englishVoiceWork != null ? englishVoiceWork.ProductName : voiceWork.ProductName,
ProductUrl = "http://www.dlsite.com/maniax/" + productLinkPage + "/=/product_id/" + voiceWork.ProductId + ".html",
Description = englishVoiceWork != null ? englishVoiceWork.Description : voiceWork.Description,
Favorite = voiceWork.Favorite,
HasImage = voiceWork.HasImage,
Maker = circle.Name,
MakerId = circle.MakerId,
ExpectedDate = voiceWork.ExpectedDate,
SalesDate = voiceWork.SalesDate,
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
Downloads = voiceWork.Downloads,
WishlistCount = voiceWork.WishlistCount,
Status = voiceWork.Status,
SubtitleLanguage = voiceWork.SubtitleLanguage,
HasTrial = voiceWork.HasTrial,
HasChobit = voiceWork.HasChobit,
IsValid = voiceWork.IsValid
};
return await result.ToDictionaryAsync(x => x.VoiceWorkId);
}
protected override async Task PostLoadAsync(IList<VoiceWorkSearchResult> items, CancellationToken cancellationToken)
{
if (items.Count == 0)