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

@@ -0,0 +1,8 @@
namespace JSMR.Application.Common;
public enum AIGeneration
{
None = 0,
Partial = 1,
Full = 2
}

View File

@@ -0,0 +1,8 @@
namespace JSMR.Application.Common;
public enum AgeRating
{
AllAges = 1,
R15 = 2,
R18 = 3
}

View File

@@ -0,0 +1,10 @@
namespace JSMR.Application.Common;
public enum Locale
{
Japanese,
English,
ChineseSimplified,
ChineseTraditional,
Korean
}

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.Common;
public enum VoiceWorkStatus
{
Available = 0,
Upcoming = 1,
NewRelease = 2,
NewAndUpcoming = 3
}

View File

@@ -1,6 +1,5 @@
using JSMR.Application.Tags.Commands.SetEnglishName; using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus; using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.VoiceWorks.Search;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace JSMR.Application.DI; namespace JSMR.Application.DI;

View File

@@ -1,5 +1,4 @@
using JSMR.Application.Common.Search; using JSMR.Application.Common.Search;
using JSMR.Application.VoiceWorks.Search.Contracts;
namespace JSMR.Application.VoiceWorks.Ports; namespace JSMR.Application.VoiceWorks.Ports;

View File

@@ -0,0 +1,8 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public enum CircleStatus
{
NotBlacklisted,
Favorited,
Blacklisted
}

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public enum CreatorStatus
{
NotBlacklisted = 1,
FavoriteExcludeBlacklist = 2,
FavoriteIncludeBlacklist = 3,
Blacklisted = 4,
}

View File

@@ -0,0 +1,8 @@
using JSMR.Application.Common.Search;
namespace JSMR.Application.VoiceWorks.Queries.Search;
public interface IVoiceWorkSearchProvider : ISearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField>
{
}

View File

@@ -0,0 +1,7 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public enum SaleStatus
{
Available = 0,
Upcoming = 2,
}

View File

@@ -1,8 +1,7 @@
using JSMR.Application.Common.Caching; using JSMR.Application.Common.Caching;
using JSMR.Application.VoiceWorks.Ports; using JSMR.Application.VoiceWorks.Ports;
using JSMR.Application.VoiceWorks.Search.Contracts;
namespace JSMR.Application.VoiceWorks.Search; namespace JSMR.Application.VoiceWorks.Queries.Search;
//public sealed class SearchVoiceWorksHandler(IVoiceWorkReader reader, ICache cache) //public sealed class SearchVoiceWorksHandler(IVoiceWorkReader reader, ICache cache)
//{ //{

View File

@@ -1,6 +1,5 @@
using JSMR.Application.Common.Search; using JSMR.Application.Common.Search;
using JSMR.Application.VoiceWorks.Search.Contracts;
namespace JSMR.Application.VoiceWorks.Search; namespace JSMR.Application.VoiceWorks.Queries.Search;
public sealed record SearchVoiceWorksRequest(SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField> Options); public sealed record SearchVoiceWorksRequest(SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField> Options);

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results);

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public enum TagStatus
{
NotBlacklisted = 1,
FavoriteExcludeBlacklist = 2,
FavoriteIncludeBlacklist = 3,
Blacklisted = 4,
}

View File

@@ -1,27 +1,26 @@
using JSMR.Application.Common; using JSMR.Application.Common;
namespace JSMR.Application.VoiceWorks.Search.Contracts; namespace JSMR.Application.VoiceWorks.Queries.Search;
public class VoiceWorkSearchCriteria public class VoiceWorkSearchCriteria
{ {
public string? Keywords { get; init; } public string? Keywords { get; init; }
public string? Title { get; init; } public string? Title { get; init; }
public string? Circle { get; init; } public string? Circle { get; init; }
//public SaleStatus SaleStatus { get; init; } public SaleStatus? SaleStatus { get; init; }
//public CircleStatus CircleStatus { get; init; } public CircleStatus? CircleStatus { get; init; }
//public TagStatus TagStatus { get; init; } public TagStatus? TagStatus { get; init; }
//public CreatorStatus CreatorStatus { get; init; } public CreatorStatus? CreatorStatus { get; init; }
public int[] TagIds { get; init; } = []; public int[] TagIds { get; init; } = [];
public bool IncludeAllTags { get; init; } public bool IncludeAllTags { get; init; }
public int[] CreatorIds { get; init; } = []; public int[] CreatorIds { get; init; } = [];
public bool IncludeAllCreators { get; init; } public bool IncludeAllCreators { get; init; }
//public VoiceWorkSort Sort { get; init; } public Locale Locale { get; init; } = Locale.Japanese;
//public VoiceWorkLanguage Language { get; init; }
public DateTime? ReleaseDateStart { get; init; } public DateTime? ReleaseDateStart { get; init; }
public DateTime? ReleaseDateEnd { get; init; } public DateTime? ReleaseDateEnd { get; init; }
//public List<AgeRating> AgeRatings { get; init; } public AgeRating[] AgeRatings { get; init; } = [];
public Language[] SupportedLanguages { get; init; } = []; public Language[] SupportedLanguages { get; init; } = [];
//public List<AIGeneration> AIGenerationOptions { get; init; } public AIGeneration[] AIGenerationOptions { get; init; } = [];
public bool ShowFavoriteVoiceWorks { get; init; } public bool ShowFavoriteVoiceWorks { get; init; }
public bool ShowInvalidVoiceWorks { get; init; } public bool ShowInvalidVoiceWorks { get; init; }
public int? MinDownloads { get; init; } public int? MinDownloads { get; init; }

View File

@@ -1,6 +1,6 @@
using JSMR.Application.Common.Search; using JSMR.Application.Common.Search;
namespace JSMR.Application.VoiceWorks.Search.Contracts; namespace JSMR.Application.VoiceWorks.Queries.Search;
//public record VoiceWorkSearchOptions : SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField> //public record VoiceWorkSearchOptions : SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>
//{ //{

View File

@@ -0,0 +1,44 @@
using JSMR.Application.Common;
namespace JSMR.Application.VoiceWorks.Queries.Search;
public record VoiceWorkSearchResult
{
public int VoiceWorkId { get; init; }
public required string ProductId { get; init; }
public string? OriginalProductId { get; init; }
public string? Description { get; init; }
public required string ProductName { get; init; }
public required string ProductUrl { get; init; }
public bool HasImage { get; init; }
public required string Maker { get; init; }
public required string MakerId { get; init; }
public DateTime? ExpectedDate { get; init; }
public DateTime? SalesDate { get; init; }
public DateTime? PlannedReleaseDate { get; init; }
public int? Downloads { get; init; }
public int? WishlistCount { get; init; }
public byte? StarRating { get; init; }
public int? Votes { get; init; }
public bool HasTrial { get; init; }
public bool HasChobit { get; init; }
public AgeRating Rating { get; init; }
public bool Favorite { get; init; }
public byte Status { get; init; }
public byte SubtitleLanguage { get; init; }
public bool? IsValid { get; init; }
public VoiceWorkTagItem[] Tags { get; set; } = [];
public VoiceWorkCreatorItem[] Creators { get; set; } = [];
}
public class VoiceWorkTagItem
{
public int TagId { get; set; }
public required string Name { get; set; }
}
public class VoiceWorkCreatorItem
{
public int CreatorId { get; set; }
public required string Name { get; set; }
}

View File

@@ -0,0 +1,8 @@
using JSMR.Application.Common.Search;
namespace JSMR.Application.VoiceWorks.Queries.Search;
public record VoiceWorkSearchResults : SearchResult<VoiceWorkSearchResult>
{
}

View File

@@ -0,0 +1,10 @@
namespace JSMR.Application.VoiceWorks.Queries.Search;
public enum VoiceWorkSortField
{
ReleaseDate,
Downloads,
WishlistCount,
SalesToWishlistRatio,
StarRating
}

View File

@@ -1,8 +0,0 @@
using JSMR.Application.Common.Search;
namespace JSMR.Application.VoiceWorks.Search.Contracts;
public record VoiceWorkSearchResults : SearchResult<object>
{
}

View File

@@ -1,11 +0,0 @@
namespace JSMR.Application.VoiceWorks.Search.Contracts;
public enum VoiceWorkSortField
{
ReleaseDateNewToOld,
ReleaseDateOldToNew,
BestSelling,
MostWishedFor,
SalesToWishlistRatio,
StarRating
}

View File

@@ -1,5 +0,0 @@
using JSMR.Application.VoiceWorks.Search.Contracts;
namespace JSMR.Application.VoiceWorks.Search;
public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results);

View File

@@ -23,6 +23,8 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
.Take(options.PageSize) .Take(options.PageSize)
.ToArrayAsync(cancellationToken); .ToArrayAsync(cancellationToken);
await PostLoadAsync(items, cancellationToken);
return new SearchResult<TItem>() return new SearchResult<TItem>()
{ {
Items = items, 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 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 IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain();
protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query); 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 _ => 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() protected override IEnumerable<(Expression<Func<CircleSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{ {
yield return (x => x.Name, SortDirection.Ascending); yield return (x => x.Name, SortDirection.Ascending);

View File

@@ -50,14 +50,9 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
return selector; 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() 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) protected override IOrderedQueryable<CreatorSearchItem> GetSelectQuery(IOrderedQueryable<CreatorSearchItem> query)

View File

@@ -1,5 +1,4 @@
using JSMR.Application.Common.Search; 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.Contracts;
using JSMR.Application.Tags.Queries.Search.Ports; using JSMR.Application.Tags.Queries.Search.Ports;
using JSMR.Infrastructure.Common.Queries; using JSMR.Infrastructure.Common.Queries;
@@ -56,14 +55,9 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
return selector; 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() 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) 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()
);
}
}

View File

@@ -0,0 +1,65 @@
using JSMR.Application.Common;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Fixtures;
public class VoiceWorkSearchProviderFixture : MariaDbFixture
{
protected override async Task OnInitializedAsync(AppDbContext context)
{
await SeedAsync(context);
}
private static async Task SeedAsync(AppDbContext context)
{
if (await context.VoiceWorks.AnyAsync())
return;
context.Circles.AddRange(
new() { CircleId = 1, Name = "Good Dreams", MakerId = "RG00001" },
new() { CircleId = 2, Name = "Sweet Dreams", Favorite = true, MakerId = "RG00002" },
new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" }
);
context.VoiceWorks.AddRange(
new() { VoiceWorkId = 1, CircleId = 1, ProductId = "RJ0000001", ProductName = "Today Sounds", Description = "An average product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 1) },
new() { VoiceWorkId = 2, CircleId = 2, ProductId = "RJ0000002", ProductName = "Super Comfy ASMR", Description = "An amazing product!", Status = (byte)VoiceWorkStatus.NewRelease, SalesDate = new(2025, 1, 3) },
new() { VoiceWorkId = 3, CircleId = 3, ProductId = "RJ0000003", ProductName = "Low Effort", Description = "A bad product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 2) },
new() { VoiceWorkId = 4, CircleId = 1, ProductId = "RJ0000004", ProductName = "Tomorrow Sounds", Description = "A average upcoming product.", Status = (byte)VoiceWorkStatus.Upcoming, ExpectedDate = new(2025, 1, 4) },
new() { VoiceWorkId = 5, CircleId = 2, ProductId = "RJ0000005", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!", Status = (byte)VoiceWorkStatus.NewAndUpcoming, ExpectedDate = new(2025, 1, 5) }
);
context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "OL" },
new() { TagId = 3, Name = "ほのぼの" },
new() { TagId = 4, Name = "エルフ/妖精" },
new() { TagId = 5, Name = "ツンデレ", Favorite = true },
new() { TagId = 6, Name = "オールハッピー" },
new() { TagId = 7, Name = "ギャル" },
new() { TagId = 8, Name = "メイド" }
);
context.EnglishTags.AddRange(
new() { EnglishTagId = 1, TagId = 1, Name = "ASMR" },
new() { EnglishTagId = 2, TagId = 2, Name = "Office Lady" },
new() { EnglishTagId = 3, TagId = 3, Name = "Heartwarming" },
new() { EnglishTagId = 4, TagId = 4, Name = "Elf / Fairy" },
new() { EnglishTagId = 5, TagId = 5, Name = "Tsundere" },
new() { EnglishTagId = 6, TagId = 6, Name = "All Happy" },
new() { EnglishTagId = 7, TagId = 7, Name = "Gal" },
new() { EnglishTagId = 8, TagId = 8, Name = "Maid" }
);
context.Creators.AddRange(
new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true },
new() { CreatorId = 2, Name = "秋野かえで" },
new() { CreatorId = 3, Name = "柚木つばめ" },
new() { CreatorId = 4, Name = "逢坂成美" },
new() { CreatorId = 5, Name = "山田じぇみ子", Blacklisted = true }
);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,92 @@
using JSMR.Application.Common.Search;
using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Tests.Fixtures;
using Shouldly;
namespace JSMR.Tests.Integration;
public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture>
{
[Fact]
public async Task Filter_Default()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.NotBlacklisted
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(2);
result.TotalItems.ShouldBe(2);
result.Items.ShouldAllBe(item => item.SalesDate != null);
result.Items.ShouldNotContain(item => item.ExpectedDate != null);
}
[Fact]
public async Task Filter_Upcoming_Favorite()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
SaleStatus = SaleStatus.Upcoming,
CircleStatus = CircleStatus.Favorited
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => item.ExpectedDate != null);
result.Items.ShouldNotContain(item => item.SalesDate != null);
}
[Fact]
public async Task Filter_Availble_Blacklisted()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.Blacklisted
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => item.SalesDate != null);
result.Items.ShouldNotContain(item => item.ExpectedDate != null);
}
}