From 3d0b2ed31d66dc1bdbdb7c2f38a24e472dec433f Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Sun, 31 Aug 2025 01:26:38 -0400 Subject: [PATCH] Added initial voice work search provider logic. --- JSMR.Application/Common/AIGeneration.cs | 8 + JSMR.Application/Common/AgeRating.cs | 8 + JSMR.Application/Common/Locale.cs | 10 + JSMR.Application/Common/VoiceWorkStatus.cs | 9 + .../ApplicationServiceCollectionExtensions.cs | 1 - .../VoiceWorks/Ports/IVoiceWorkReader.cs | 1 - .../VoiceWorks/Queries/Search/CircleStatus.cs | 8 + .../Queries/Search/CreatorStatus.cs | 9 + .../Search/IVoiceWorkSearchProvider.cs | 8 + .../VoiceWorks/Queries/Search/SaleStatus.cs | 7 + .../Search/SearchVoiceWorksHandler.cs | 3 +- .../Search/SearchVoiceWorksRequest.cs | 3 +- .../Search/SearchVoiceWorksResponse.cs | 3 + .../VoiceWorks/Queries/Search/TagStatus.cs | 9 + .../Search}/VoiceWorkSearchCriteria.cs | 17 +- .../Search}/VoiceWorkSearchOptions.cs | 2 +- .../Queries/Search/VoiceWorkSearchResult.cs | 44 +++ .../Queries/Search/VoiceWorkSearchResults.cs | 8 + .../Queries/Search/VoiceWorkSortField.cs | 10 + .../Contracts/VoiceWorkSearchResults.cs | 8 - .../Search/Contracts/VoiceWorkSortField.cs | 11 - .../Search/SearchVoiceWorksResponse.cs | 5 - .../Common/Queries/SearchProvider.cs | 5 +- .../Circles/CircleSearchProvider.cs | 3 - .../Creators/CreatorSearchProvider.cs | 7 +- .../Repositories/Tags/TagSearchProvider.cs | 8 +- .../VoiceWorks/VoiceWorkSearchProvider.cs | 342 ++++++++++++++++++ .../VoiceWorkSearchProviderFixture.cs | 65 ++++ .../VoiceWorkSearchProviderTests.cs | 92 +++++ 29 files changed, 656 insertions(+), 58 deletions(-) create mode 100644 JSMR.Application/Common/AIGeneration.cs create mode 100644 JSMR.Application/Common/AgeRating.cs create mode 100644 JSMR.Application/Common/Locale.cs create mode 100644 JSMR.Application/Common/VoiceWorkStatus.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/CircleStatus.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/CreatorStatus.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/IVoiceWorkSearchProvider.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs rename JSMR.Application/VoiceWorks/{ => Queries}/Search/SearchVoiceWorksHandler.cs (91%) rename JSMR.Application/VoiceWorks/{ => Queries}/Search/SearchVoiceWorksRequest.cs (61%) create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs rename JSMR.Application/VoiceWorks/{Search/Contracts => Queries/Search}/VoiceWorkSearchCriteria.cs (60%) rename JSMR.Application/VoiceWorks/{Search/Contracts => Queries/Search}/VoiceWorkSearchOptions.cs (72%) create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResult.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResults.cs create mode 100644 JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSortField.cs delete mode 100644 JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchResults.cs delete mode 100644 JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSortField.cs delete mode 100644 JSMR.Application/VoiceWorks/Search/SearchVoiceWorksResponse.cs create mode 100644 JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs create mode 100644 JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs create mode 100644 JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs diff --git a/JSMR.Application/Common/AIGeneration.cs b/JSMR.Application/Common/AIGeneration.cs new file mode 100644 index 0000000..400204a --- /dev/null +++ b/JSMR.Application/Common/AIGeneration.cs @@ -0,0 +1,8 @@ +namespace JSMR.Application.Common; + +public enum AIGeneration +{ + None = 0, + Partial = 1, + Full = 2 +} \ No newline at end of file diff --git a/JSMR.Application/Common/AgeRating.cs b/JSMR.Application/Common/AgeRating.cs new file mode 100644 index 0000000..d18b93d --- /dev/null +++ b/JSMR.Application/Common/AgeRating.cs @@ -0,0 +1,8 @@ +namespace JSMR.Application.Common; + +public enum AgeRating +{ + AllAges = 1, + R15 = 2, + R18 = 3 +} \ No newline at end of file diff --git a/JSMR.Application/Common/Locale.cs b/JSMR.Application/Common/Locale.cs new file mode 100644 index 0000000..7f5af84 --- /dev/null +++ b/JSMR.Application/Common/Locale.cs @@ -0,0 +1,10 @@ +namespace JSMR.Application.Common; + +public enum Locale +{ + Japanese, + English, + ChineseSimplified, + ChineseTraditional, + Korean +} \ No newline at end of file diff --git a/JSMR.Application/Common/VoiceWorkStatus.cs b/JSMR.Application/Common/VoiceWorkStatus.cs new file mode 100644 index 0000000..bba3973 --- /dev/null +++ b/JSMR.Application/Common/VoiceWorkStatus.cs @@ -0,0 +1,9 @@ +namespace JSMR.Application.Common; + +public enum VoiceWorkStatus +{ + Available = 0, + Upcoming = 1, + NewRelease = 2, + NewAndUpcoming = 3 +} \ No newline at end of file diff --git a/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs b/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs index 0ecb076..5017851 100644 --- a/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs +++ b/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs @@ -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; diff --git a/JSMR.Application/VoiceWorks/Ports/IVoiceWorkReader.cs b/JSMR.Application/VoiceWorks/Ports/IVoiceWorkReader.cs index 2edf40d..7e62dc8 100644 --- a/JSMR.Application/VoiceWorks/Ports/IVoiceWorkReader.cs +++ b/JSMR.Application/VoiceWorks/Ports/IVoiceWorkReader.cs @@ -1,5 +1,4 @@ using JSMR.Application.Common.Search; -using JSMR.Application.VoiceWorks.Search.Contracts; namespace JSMR.Application.VoiceWorks.Ports; diff --git a/JSMR.Application/VoiceWorks/Queries/Search/CircleStatus.cs b/JSMR.Application/VoiceWorks/Queries/Search/CircleStatus.cs new file mode 100644 index 0000000..178a626 --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/CircleStatus.cs @@ -0,0 +1,8 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public enum CircleStatus +{ + NotBlacklisted, + Favorited, + Blacklisted +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/CreatorStatus.cs b/JSMR.Application/VoiceWorks/Queries/Search/CreatorStatus.cs new file mode 100644 index 0000000..3c92c7b --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/CreatorStatus.cs @@ -0,0 +1,9 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public enum CreatorStatus +{ + NotBlacklisted = 1, + FavoriteExcludeBlacklist = 2, + FavoriteIncludeBlacklist = 3, + Blacklisted = 4, +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/IVoiceWorkSearchProvider.cs b/JSMR.Application/VoiceWorks/Queries/Search/IVoiceWorkSearchProvider.cs new file mode 100644 index 0000000..f967312 --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/IVoiceWorkSearchProvider.cs @@ -0,0 +1,8 @@ +using JSMR.Application.Common.Search; + +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public interface IVoiceWorkSearchProvider : ISearchProvider +{ + +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs b/JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs new file mode 100644 index 0000000..af7c738 --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/SaleStatus.cs @@ -0,0 +1,7 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public enum SaleStatus +{ + Available = 0, + Upcoming = 2, +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksHandler.cs b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs similarity index 91% rename from JSMR.Application/VoiceWorks/Search/SearchVoiceWorksHandler.cs rename to JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs index f65660f..c2d9f46 100644 --- a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksHandler.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs @@ -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) //{ diff --git a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksRequest.cs b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksRequest.cs similarity index 61% rename from JSMR.Application/VoiceWorks/Search/SearchVoiceWorksRequest.cs rename to JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksRequest.cs index 15699f1..5282c40 100644 --- a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksRequest.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksRequest.cs @@ -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 Options); \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs new file mode 100644 index 0000000..c31007a --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs @@ -0,0 +1,3 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results); \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs b/JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs new file mode 100644 index 0000000..4df4547 --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/TagStatus.cs @@ -0,0 +1,9 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public enum TagStatus +{ + NotBlacklisted = 1, + FavoriteExcludeBlacklist = 2, + FavoriteIncludeBlacklist = 3, + Blacklisted = 4, +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchCriteria.cs b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchCriteria.cs similarity index 60% rename from JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchCriteria.cs rename to JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchCriteria.cs index be1ceef..75be8b5 100644 --- a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchCriteria.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchCriteria.cs @@ -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 AgeRatings { get; init; } + public AgeRating[] AgeRatings { get; init; } = []; public Language[] SupportedLanguages { get; init; } = []; - //public List AIGenerationOptions { get; init; } + public AIGeneration[] AIGenerationOptions { get; init; } = []; public bool ShowFavoriteVoiceWorks { get; init; } public bool ShowInvalidVoiceWorks { get; init; } public int? MinDownloads { get; init; } diff --git a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchOptions.cs b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchOptions.cs similarity index 72% rename from JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchOptions.cs rename to JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchOptions.cs index 88486eb..2a5f5d0 100644 --- a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchOptions.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchOptions.cs @@ -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 //{ diff --git a/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResult.cs b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResult.cs new file mode 100644 index 0000000..eba90ec --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResult.cs @@ -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; } +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResults.cs b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResults.cs new file mode 100644 index 0000000..809362f --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSearchResults.cs @@ -0,0 +1,8 @@ +using JSMR.Application.Common.Search; + +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public record VoiceWorkSearchResults : SearchResult +{ + +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSortField.cs b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSortField.cs new file mode 100644 index 0000000..b4acace --- /dev/null +++ b/JSMR.Application/VoiceWorks/Queries/Search/VoiceWorkSortField.cs @@ -0,0 +1,10 @@ +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public enum VoiceWorkSortField +{ + ReleaseDate, + Downloads, + WishlistCount, + SalesToWishlistRatio, + StarRating +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchResults.cs b/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchResults.cs deleted file mode 100644 index 59dc2aa..0000000 --- a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSearchResults.cs +++ /dev/null @@ -1,8 +0,0 @@ -using JSMR.Application.Common.Search; - -namespace JSMR.Application.VoiceWorks.Search.Contracts; - -public record VoiceWorkSearchResults : SearchResult -{ - -} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSortField.cs b/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSortField.cs deleted file mode 100644 index 07e6cf0..0000000 --- a/JSMR.Application/VoiceWorks/Search/Contracts/VoiceWorkSortField.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace JSMR.Application.VoiceWorks.Search.Contracts; - -public enum VoiceWorkSortField -{ - ReleaseDateNewToOld, - ReleaseDateOldToNew, - BestSelling, - MostWishedFor, - SalesToWishlistRatio, - StarRating -} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksResponse.cs b/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksResponse.cs deleted file mode 100644 index f554d8e..0000000 --- a/JSMR.Application/VoiceWorks/Search/SearchVoiceWorksResponse.cs +++ /dev/null @@ -1,5 +0,0 @@ -using JSMR.Application.VoiceWorks.Search.Contracts; - -namespace JSMR.Application.VoiceWorks.Search; - -public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results); \ No newline at end of file diff --git a/JSMR.Infrastructure/Common/Queries/SearchProvider.cs b/JSMR.Infrastructure/Common/Queries/SearchProvider.cs index 7040abc..9dde625 100644 --- a/JSMR.Infrastructure/Common/Queries/SearchProvider.cs +++ b/JSMR.Infrastructure/Common/Queries/SearchProvider.cs @@ -23,6 +23,8 @@ public abstract class SearchProvider : .Take(options.PageSize) .ToArrayAsync(cancellationToken); + await PostLoadAsync(items, cancellationToken); + return new SearchResult() { Items = items, @@ -82,8 +84,7 @@ public abstract class SearchProvider : } protected abstract Expression> GetSortExpression(TSortField field); - protected abstract IOrderedQueryable GetDefaultSortExpression(IQueryable query); - //protected abstract (Expression> Selector, SortDirection Direction) GetDefaultSortExpression(); protected abstract IEnumerable<(Expression> Selector, SortDirection Dir)> GetDefaultSortChain(); protected abstract IQueryable GetSelectQuery(IOrderedQueryable query); + protected virtual Task PostLoadAsync(IList items, CancellationToken cancellationToken) => Task.CompletedTask; } \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs index ba9e990..849e757 100644 --- a/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs +++ b/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs @@ -109,9 +109,6 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider x => x.Name }; - protected override IOrderedQueryable GetDefaultSortExpression(IQueryable query) - => query.OrderBy(x => x.Name).ThenBy(x => x.CircleId); - protected override IEnumerable<(Expression> Selector, SortDirection Dir)> GetDefaultSortChain() { yield return (x => x.Name, SortDirection.Ascending); diff --git a/JSMR.Infrastructure/Data/Repositories/Creators/CreatorSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/Creators/CreatorSearchProvider.cs index 4077562..0da300e 100644 --- a/JSMR.Infrastructure/Data/Repositories/Creators/CreatorSearchProvider.cs +++ b/JSMR.Infrastructure/Data/Repositories/Creators/CreatorSearchProvider.cs @@ -50,14 +50,9 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider GetDefaultSortExpression(IQueryable query) - { - return query.OrderBy(x => x.Name); - } - protected override IEnumerable<(Expression> Selector, SortDirection Dir)> GetDefaultSortChain() { - yield return (x => x.Name ?? string.Empty, SortDirection.Ascending); + yield return (x => x.Name, SortDirection.Ascending); } protected override IOrderedQueryable GetSelectQuery(IOrderedQueryable query) diff --git a/JSMR.Infrastructure/Data/Repositories/Tags/TagSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/Tags/TagSearchProvider.cs index 3eac752..6fab37a 100644 --- a/JSMR.Infrastructure/Data/Repositories/Tags/TagSearchProvider.cs +++ b/JSMR.Infrastructure/Data/Repositories/Tags/TagSearchProvider.cs @@ -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 GetDefaultSortExpression(IQueryable query) - { - return query.OrderBy(x => x.Name); - } - protected override IEnumerable<(Expression> Selector, SortDirection Dir)> GetDefaultSortChain() { - yield return (x => x.Name ?? string.Empty, SortDirection.Ascending); + yield return (x => x.Name, SortDirection.Ascending); } protected override IOrderedQueryable GetSelectQuery(IOrderedQueryable query) diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs new file mode 100644 index 0000000..1ed4933 --- /dev/null +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs @@ -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, IVoiceWorkSearchProvider +{ + protected override IQueryable 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 ApplyFilters(IQueryable query, VoiceWorkSearchCriteria criteria) + { + IQueryable 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 ApplyTagStatusFilter(IQueryable 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 FilterTagIds(IQueryable 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 FilterCreatorIds(IQueryable 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> Selector, SortDirection Dir)> GetDefaultSortChain() + { + yield return (x => x.VoiceWork.ProductId, SortDirection.Ascending); + } + + protected override Expression> 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 GetSelectQuery(IOrderedQueryable 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 items, CancellationToken cancellationToken) + { + if (items.Count == 0) + return; + + int[] voiceWorkIds = [.. items.Select(i => i.VoiceWorkId)]; + + Dictionary tagsByVw = await GetTagsAsync(voiceWorkIds, cancellationToken); + Dictionary 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> 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> 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() + ); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs b/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs new file mode 100644 index 0000000..29016a6 --- /dev/null +++ b/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs @@ -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(); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs new file mode 100644 index 0000000..fb40fa6 --- /dev/null +++ b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs @@ -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 +{ + [Fact] + public async Task Filter_Default() + { + await using AppDbContext context = fixture.CreateDbContext(); + VoiceWorkSearchProvider provider = new(context); + + var options = new SearchOptions() + { + 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() + { + 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() + { + 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); + } +} \ No newline at end of file