Added initial voice work search provider logic.

This commit is contained in:
2025-08-31 01:26:38 -04:00
parent 2785566801
commit 3d0b2ed31d
29 changed files with 656 additions and 58 deletions

View File

@@ -23,6 +23,8 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
await PostLoadAsync(items, cancellationToken);
return new SearchResult<TItem>()
{
Items = items,
@@ -82,8 +84,7 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
}
protected abstract Expression<Func<TBaseQuery, object>> GetSortExpression(TSortField field);
protected abstract IOrderedQueryable<TBaseQuery> GetDefaultSortExpression(IQueryable<TBaseQuery> query);
//protected abstract (Expression<Func<TBaseQuery, object>> Selector, SortDirection Direction) GetDefaultSortExpression();
protected abstract IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain();
protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query);
protected virtual Task PostLoadAsync(IList<TItem> items, CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -109,9 +109,6 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
_ => x => x.Name
};
protected override IOrderedQueryable<CircleSearchItem> GetDefaultSortExpression(IQueryable<CircleSearchItem> query)
=> query.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
protected override IEnumerable<(Expression<Func<CircleSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{
yield return (x => x.Name, SortDirection.Ascending);

View File

@@ -50,14 +50,9 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
return selector;
}
protected override IOrderedQueryable<CreatorSearchItem> GetDefaultSortExpression(IQueryable<CreatorSearchItem> query)
{
return query.OrderBy(x => x.Name);
}
protected override IEnumerable<(Expression<Func<CreatorSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{
yield return (x => x.Name ?? string.Empty, SortDirection.Ascending);
yield return (x => x.Name, SortDirection.Ascending);
}
protected override IOrderedQueryable<CreatorSearchItem> GetSelectQuery(IOrderedQueryable<CreatorSearchItem> query)

View File

@@ -1,5 +1,4 @@
using JSMR.Application.Common.Search;
using JSMR.Application.Creators.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Ports;
using JSMR.Infrastructure.Common.Queries;
@@ -56,14 +55,9 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
return selector;
}
protected override IOrderedQueryable<TagSearchItem> GetDefaultSortExpression(IQueryable<TagSearchItem> query)
{
return query.OrderBy(x => x.Name);
}
protected override IEnumerable<(Expression<Func<TagSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{
yield return (x => x.Name ?? string.Empty, SortDirection.Ascending);
yield return (x => x.Name, SortDirection.Ascending);
}
protected override IOrderedQueryable<TagSearchItem> GetSelectQuery(IOrderedQueryable<TagSearchItem> query)

View File

@@ -0,0 +1,342 @@
using JSMR.Application.Common;
using JSMR.Application.Common.Search;
using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using CircleStatus = JSMR.Application.VoiceWorks.Queries.Search.CircleStatus;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
public class VoiceWorkQuery
{
public required VoiceWork VoiceWork { get; init; }
public EnglishVoiceWork? EnglishVoiceWork { get; init; }
public required Circle Circle { get; init; }
//public VoiceWorkLocalization? VoiceWorkLocalization { get; init; }
//public VoiceWorkSearch? VoiceWorkSearch { get; init; }
}
public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider
{
protected override IQueryable<VoiceWorkQuery> GetBaseQuery()
{
return
from voiceWork in context.VoiceWorks
join englishVoiceWork in context.EnglishVoiceWorks on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
from englishVoiceWork in ps.DefaultIfEmpty()
join circle in context.Circles on voiceWork.CircleId equals circle.CircleId into cs
from circle in cs.DefaultIfEmpty()
//join voiceWorkLocalization in context.VoiceWorkLocalizations on voiceWork.VoiceWorkId equals voiceWorkLocalization.VoiceWorkId into vwl
//from voiceWorkLocalization in vwl.DefaultIfEmpty()
//join voiceWorkSearch in context.VoiceWorkSearches on voiceWork.VoiceWorkId equals voiceWorkSearch.VoiceWorkId into vws
//from voiceWorkSearch in vws.DefaultIfEmpty()
select new VoiceWorkQuery
{
VoiceWork = voiceWork,
EnglishVoiceWork = englishVoiceWork,
Circle = circle
};
}
protected override IQueryable<VoiceWorkQuery> ApplyFilters(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{
IQueryable<VoiceWorkQuery> filteredQuery = query;
// TODO: Full Text Search implementation
//filteredQuery = FuzzyKeywordSearch(filteredQuery, searchProperties.Keywords);
//filteredQuery = FuzzyTitleSearch(filteredQuery, searchProperties.Title);
//filteredQuery = FuzzyCircleSearch(filteredQuery, searchProperties.Circle);
switch (criteria.SaleStatus)
{
case SaleStatus.Available:
filteredQuery = filteredQuery.Where(x => x.VoiceWork.SalesDate != null);
break;
case SaleStatus.Upcoming:
filteredQuery = filteredQuery.Where(x => x.VoiceWork.ExpectedDate != null);
break;
}
if (criteria.ReleaseDateStart is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.SalesDate >= criteria.ReleaseDateStart.Value);
if (criteria.ReleaseDateEnd is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.SalesDate <= criteria.ReleaseDateEnd.Value);
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.AIGenerationOptions.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.Contains((AIGeneration)x.VoiceWork.AIGeneration));
if (criteria.ShowFavoriteVoiceWorks)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Favorite);
if (criteria.ShowInvalidVoiceWorks)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.IsValid != true);
if (criteria.MinDownloads is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Downloads >= criteria.MinDownloads.Value);
if (criteria.MaxDownloads is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Downloads <= criteria.MaxDownloads.Value);
switch (criteria.CircleStatus)
{
case CircleStatus.NotBlacklisted:
filteredQuery = filteredQuery.Where(x => x.Circle.Blacklisted == false);
break;
case CircleStatus.Favorited:
filteredQuery = filteredQuery.Where(x => x.Circle.Favorite);
break;
case CircleStatus.Blacklisted:
filteredQuery = filteredQuery.Where(x => x.Circle.Blacklisted);
break;
}
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
//filteredQuery = FilterCreatorStatus(filteredQuery, searchProperties.CreatorStatus, _voiceWorkContext);
filteredQuery = FilterTagIds(filteredQuery, criteria);
filteredQuery = FilterCreatorIds(filteredQuery, criteria);
return filteredQuery;
}
private IQueryable<VoiceWorkQuery> ApplyTagStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{
if (criteria.TagStatus is null)
return query;
// Handy local predicates that translate to EXISTS subqueries
bool HasFav(int voiceWorkId) =>
context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == voiceWorkId && x.t.Favorite);
bool HasBlk(int voiceWorkId) =>
context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == voiceWorkId && x.t.Blacklisted);
return criteria.TagStatus switch
{
TagStatus.NotBlacklisted =>
query.Where(q => !context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == q.VoiceWork.VoiceWorkId && x.t.Blacklisted)),
TagStatus.Blacklisted =>
query.Where(q => context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == q.VoiceWork.VoiceWorkId && x.t.Blacklisted)),
TagStatus.FavoriteIncludeBlacklist =>
query.Where(q => context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == q.VoiceWork.VoiceWorkId && x.t.Favorite)),
TagStatus.FavoriteExcludeBlacklist =>
query.Where(q =>
context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == q.VoiceWork.VoiceWorkId && x.t.Favorite)
&&
!context.VoiceWorkTags
.Join(context.Tags, vwt => vwt.TagId, t => t.TagId, (vwt, t) => new { vwt, t })
.Any(x => x.vwt.VoiceWorkId == q.VoiceWork.VoiceWorkId && x.t.Blacklisted)
),
_ => query
};
}
private IQueryable<VoiceWorkQuery> FilterTagIds(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria)
{
if (criteria.TagIds.Length == 0)
return filteredQuery;
if (criteria.IncludeAllTags == false)
{
var tagQuery =
from voiceWorkTag in context.VoiceWorkTags.AsNoTracking()
where criteria.TagIds.Contains(voiceWorkTag.TagId)
select new { voiceWorkTag };
var finalTagQuery = tagQuery.Select(x => x.voiceWorkTag.VoiceWorkId);
filteredQuery = filteredQuery.Where(x => finalTagQuery.Contains(x.VoiceWork.VoiceWorkId));
}
else
{
foreach (int tagId in criteria.TagIds)
{
var tagIdQuery =
from voiceWorkTag in context.VoiceWorkTags.AsNoTracking()
where voiceWorkTag.TagId == tagId
select voiceWorkTag.VoiceWorkId;
filteredQuery =
from query in filteredQuery
join voiceWorkId in tagIdQuery on query.VoiceWork.VoiceWorkId equals voiceWorkId
select new VoiceWorkQuery
{
VoiceWork = query.VoiceWork,
EnglishVoiceWork = query.EnglishVoiceWork,
Circle = query.Circle
};
}
}
return filteredQuery;
}
private IQueryable<VoiceWorkQuery> FilterCreatorIds(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria)
{
if (criteria.CreatorIds.Length == 0)
return filteredQuery;
if (criteria.IncludeAllCreators == false)
{
var creatorQuery =
from voiceWorkCreator in context.VoiceWorkCreators.AsNoTracking()
where criteria.CreatorIds.Contains(voiceWorkCreator.CreatorId)
select new { voiceWorkCreator };
var finalCreatorQuery = creatorQuery.Select(x => x.voiceWorkCreator.VoiceWorkId);
filteredQuery = filteredQuery.Where(x => finalCreatorQuery.Contains(x.VoiceWork.VoiceWorkId));
}
else
{
foreach (int creatorId in criteria.CreatorIds)
{
var creatorIdQuery =
from voiceWorkCreator in context.VoiceWorkCreators.AsNoTracking()
where voiceWorkCreator.CreatorId == creatorId
select voiceWorkCreator.VoiceWorkId;
filteredQuery =
from query in filteredQuery
join voiceWorkId in creatorIdQuery on query.VoiceWork.VoiceWorkId equals voiceWorkId
select new VoiceWorkQuery
{
VoiceWork = query.VoiceWork,
EnglishVoiceWork = query.EnglishVoiceWork,
Circle = query.Circle
};
}
}
return filteredQuery;
}
protected override IEnumerable<(Expression<Func<VoiceWorkQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{
yield return (x => x.VoiceWork.ProductId, SortDirection.Ascending);
}
protected override Expression<Func<VoiceWorkQuery, object>> GetSortExpression(VoiceWorkSortField field) => field switch
{
VoiceWorkSortField.ReleaseDate => x => x.VoiceWork.SalesDate ?? x.VoiceWork.ExpectedDate ?? DateTime.MinValue,
VoiceWorkSortField.Downloads => x => x.VoiceWork.Downloads ?? 0,
VoiceWorkSortField.WishlistCount => x => x.VoiceWork.WishlistCount ?? 0,
VoiceWorkSortField.StarRating => x => x.VoiceWork.StarRating ?? 0,
_ => x => x.VoiceWork.ProductId
};
protected override IQueryable<VoiceWorkSearchResult> GetSelectQuery(IOrderedQueryable<VoiceWorkQuery> query)
{
var result =
from q in query
let voiceWork = q.VoiceWork
let englishVoiceWork = q.EnglishVoiceWork
let circle = q.Circle
let productLinkPage = voiceWork.SalesDate.HasValue ? "work" : "announce"
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 result;
}
protected override async Task PostLoadAsync(IList<VoiceWorkSearchResult> items, CancellationToken cancellationToken)
{
if (items.Count == 0)
return;
int[] voiceWorkIds = [.. items.Select(i => i.VoiceWorkId)];
Dictionary<int, VoiceWorkTagItem[]> tagsByVw = await GetTagsAsync(voiceWorkIds, cancellationToken);
Dictionary<int, VoiceWorkCreatorItem[]> creatorsByVw = await GetCreatorsAsync(voiceWorkIds, cancellationToken);
foreach (VoiceWorkSearchResult item in items)
{
if (tagsByVw.TryGetValue(item.VoiceWorkId, out VoiceWorkTagItem[]? tags))
item.Tags = tags;
if (creatorsByVw.TryGetValue(item.VoiceWorkId, out VoiceWorkCreatorItem[]? creators))
item.Creators = creators;
}
}
private async Task<Dictionary<int, VoiceWorkTagItem[]>> GetTagsAsync(int[] voiceWorkIds, CancellationToken cancellationToken)
{
var tagRows = await (
from voiceWorkTag in context.VoiceWorkTags.AsNoTracking()
join tag in context.Tags.AsNoTracking() on voiceWorkTag.TagId equals tag.TagId
where voiceWorkIds.Contains(voiceWorkTag.VoiceWorkId)
orderby voiceWorkTag.VoiceWorkId, voiceWorkTag.Position
select new { voiceWorkTag.VoiceWorkId, voiceWorkTag.TagId, tag.Name }
).ToListAsync(cancellationToken);
return tagRows
.GroupBy(r => r.VoiceWorkId)
.ToDictionary(
g => g.Key,
g => g.Select(r => new VoiceWorkTagItem { TagId = r.TagId, Name = r.Name }).ToArray()
);
}
private async Task<Dictionary<int, VoiceWorkCreatorItem[]>> GetCreatorsAsync(int[] voiceWorkIds, CancellationToken cancellationToken)
{
var creatorRows = await (
from voiceWorkCreator in context.VoiceWorkCreators.AsNoTracking()
join creator in context.Creators.AsNoTracking() on voiceWorkCreator.CreatorId equals creator.CreatorId
where voiceWorkIds.Contains(voiceWorkCreator.VoiceWorkId)
orderby voiceWorkCreator.VoiceWorkId, voiceWorkCreator.Position
select new { voiceWorkCreator.VoiceWorkId, creator.CreatorId, creator.Name }
).ToListAsync(cancellationToken);
return creatorRows
.GroupBy(r => r.VoiceWorkId)
.ToDictionary(
g => g.Key,
g => g.Select(r => new VoiceWorkCreatorItem { CreatorId = r.CreatorId, Name = r.Name }).ToArray()
);
}
}