Added initial voice work search provider logic.
This commit is contained in:
8
JSMR.Application/Common/AIGeneration.cs
Normal file
8
JSMR.Application/Common/AIGeneration.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace JSMR.Application.Common;
|
||||
|
||||
public enum AIGeneration
|
||||
{
|
||||
None = 0,
|
||||
Partial = 1,
|
||||
Full = 2
|
||||
}
|
||||
8
JSMR.Application/Common/AgeRating.cs
Normal file
8
JSMR.Application/Common/AgeRating.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace JSMR.Application.Common;
|
||||
|
||||
public enum AgeRating
|
||||
{
|
||||
AllAges = 1,
|
||||
R15 = 2,
|
||||
R18 = 3
|
||||
}
|
||||
10
JSMR.Application/Common/Locale.cs
Normal file
10
JSMR.Application/Common/Locale.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace JSMR.Application.Common;
|
||||
|
||||
public enum Locale
|
||||
{
|
||||
Japanese,
|
||||
English,
|
||||
ChineseSimplified,
|
||||
ChineseTraditional,
|
||||
Korean
|
||||
}
|
||||
9
JSMR.Application/Common/VoiceWorkStatus.cs
Normal file
9
JSMR.Application/Common/VoiceWorkStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace JSMR.Application.Common;
|
||||
|
||||
public enum VoiceWorkStatus
|
||||
{
|
||||
Available = 0,
|
||||
Upcoming = 1,
|
||||
NewRelease = 2,
|
||||
NewAndUpcoming = 3
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using JSMR.Application.Tags.Commands.SetEnglishName;
|
||||
using JSMR.Application.Tags.Commands.UpdateTagStatus;
|
||||
using JSMR.Application.VoiceWorks.Search;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace JSMR.Application.DI;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using JSMR.Application.Common.Search;
|
||||
using JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Ports;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public enum CircleStatus
|
||||
{
|
||||
NotBlacklisted,
|
||||
Favorited,
|
||||
Blacklisted
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public enum CreatorStatus
|
||||
{
|
||||
NotBlacklisted = 1,
|
||||
FavoriteExcludeBlacklist = 2,
|
||||
FavoriteIncludeBlacklist = 3,
|
||||
Blacklisted = 4,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using JSMR.Application.Common.Search;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public interface IVoiceWorkSearchProvider : ISearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField>
|
||||
{
|
||||
|
||||
}
|
||||
7
JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs
Normal file
7
JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public enum SaleStatus
|
||||
{
|
||||
Available = 0,
|
||||
Upcoming = 2,
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
using JSMR.Application.Common.Caching;
|
||||
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)
|
||||
//{
|
||||
@@ -1,6 +1,5 @@
|
||||
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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results);
|
||||
9
JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs
Normal file
9
JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public enum TagStatus
|
||||
{
|
||||
NotBlacklisted = 1,
|
||||
FavoriteExcludeBlacklist = 2,
|
||||
FavoriteIncludeBlacklist = 3,
|
||||
Blacklisted = 4,
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
using JSMR.Application.Common;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public class VoiceWorkSearchCriteria
|
||||
{
|
||||
public string? Keywords { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Circle { get; init; }
|
||||
//public SaleStatus SaleStatus { get; init; }
|
||||
//public CircleStatus CircleStatus { get; init; }
|
||||
//public TagStatus TagStatus { get; init; }
|
||||
//public CreatorStatus CreatorStatus { get; init; }
|
||||
public SaleStatus? SaleStatus { get; init; }
|
||||
public CircleStatus? CircleStatus { get; init; }
|
||||
public TagStatus? TagStatus { get; init; }
|
||||
public CreatorStatus? CreatorStatus { get; init; }
|
||||
public int[] TagIds { get; init; } = [];
|
||||
public bool IncludeAllTags { get; init; }
|
||||
public int[] CreatorIds { get; init; } = [];
|
||||
public bool IncludeAllCreators { get; init; }
|
||||
//public VoiceWorkSort Sort { get; init; }
|
||||
//public VoiceWorkLanguage Language { get; init; }
|
||||
public Locale Locale { get; init; } = Locale.Japanese;
|
||||
public DateTime? ReleaseDateStart { get; init; }
|
||||
public DateTime? ReleaseDateEnd { get; init; }
|
||||
//public List<AgeRating> AgeRatings { get; init; }
|
||||
public AgeRating[] AgeRatings { 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 ShowInvalidVoiceWorks { get; init; }
|
||||
public int? MinDownloads { get; init; }
|
||||
@@ -1,6 +1,6 @@
|
||||
using JSMR.Application.Common.Search;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
//public record VoiceWorkSearchOptions : SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>
|
||||
//{
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using JSMR.Application.Common.Search;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public record VoiceWorkSearchResults : SearchResult<VoiceWorkSearchResult>
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace JSMR.Application.VoiceWorks.Queries.Search;
|
||||
|
||||
public enum VoiceWorkSortField
|
||||
{
|
||||
ReleaseDate,
|
||||
Downloads,
|
||||
WishlistCount,
|
||||
SalesToWishlistRatio,
|
||||
StarRating
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using JSMR.Application.Common.Search;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
|
||||
public record VoiceWorkSearchResults : SearchResult<object>
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
|
||||
public enum VoiceWorkSortField
|
||||
{
|
||||
ReleaseDateNewToOld,
|
||||
ReleaseDateOldToNew,
|
||||
BestSelling,
|
||||
MostWishedFor,
|
||||
SalesToWishlistRatio,
|
||||
StarRating
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using JSMR.Application.VoiceWorks.Search.Contracts;
|
||||
|
||||
namespace JSMR.Application.VoiceWorks.Search;
|
||||
|
||||
public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
65
JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs
Normal file
65
JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
92
JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs
Normal file
92
JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user