Add project files.

This commit is contained in:
2025-08-26 09:20:13 -04:00
parent 6c6a149821
commit d2201d6f9b
118 changed files with 1924 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data;
public class AppDbContext : DbContext
{
public DbSet<VoiceWork> VoiceWorks { get; set; }
public DbSet<EnglishVoiceWork> EnglishVoiceWorks { get; set; }
public DbSet<Circle> Circles { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<EnglishTag> EnglishTags { get; set; }
public DbSet<VoiceWorkTag> VoiceWorkTags { get; set; }
public DbSet<Creator> Creators { get; set; }
public DbSet<VoiceWorkCreator> VoiceWorkCreators { get; set; }
public DbSet<Series> Series { get; set; }
public DbSet<VoiceWorkSearch> VoiceWorkSearches { get; set; }
}

View File

@@ -0,0 +1,35 @@
using JSMR.Application.Circles.Queries.GetCreators;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleCreatorsProvider(AppDbContext context) : ICircleCreatorsProvider
{
public async Task<GetCircleCreatorsResponse> HandleAsync(GetCircleCreatorsRequest request, CancellationToken cancellationToken = default)
{
int? circleId = await CircleLookup.ResolveCircleIdAsync(context, request.CircleId, request.NameOrMakerId, cancellationToken);
if (circleId is null)
return new GetCircleCreatorsResponse([]);
IQueryable<CircleCreatorItem> query =
from voiceWork in context.VoiceWorks.AsNoTracking()
where voiceWork.CircleId == circleId.Value
join voiceWorkCreator in context.VoiceWorkCreators.AsNoTracking() on voiceWork.VoiceWorkId equals voiceWorkCreator.VoiceWorkId
join creator in context.Creators.AsNoTracking() on voiceWorkCreator.CreatorId equals creator.CreatorId
group new { creator.Name, voiceWork.SalesDate, voiceWork.Downloads } by creator.Name into g
select new CircleCreatorItem(
g.Key,
g.Min(x => x.SalesDate),
g.Max(x => x.SalesDate),
g.Sum(x => x.Downloads ?? 0),
g.Count());
CircleCreatorItem[] items = await query
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.Downloads)
.ToArrayAsync(cancellationToken);
return new GetCircleCreatorsResponse(items);
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
internal static class CircleLookup
{
public static async Task<int?> ResolveCircleIdAsync(AppDbContext context, int? circleId, string? nameOrMakerId, CancellationToken cancellationToken)
{
if (circleId.HasValue)
return circleId.Value;
if (!string.IsNullOrWhiteSpace(nameOrMakerId))
{
return await context.Circles
.Where(c => c.Name == nameOrMakerId || c.MakerId == nameOrMakerId)
.Select(c => (int?)c.CircleId)
.FirstOrDefaultAsync(cancellationToken);
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleSearchItem, CircleSearchCriteria, CircleSortField, CircleSearchItem>, ICircleSearchProvider
{
protected override IQueryable<CircleSearchItem> GetBaseQuery()
{
// Precompute LatestProductId per circle (by productId length, then value)
var latestPerCircle =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
let latest = g
.OrderByDescending(x => x.ProductId.Length)
.ThenByDescending(x => x.ProductId)
.Select(x => x.ProductId)
.FirstOrDefault()
select new { CircleId = g.Key, LatestProductId = latest };
// Aggregates per circle
var aggregates =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
select new
{
CircleId = g.Key,
Downloads = g.Sum(x => x.Downloads ?? 0),
Releases = g.Count(x => x.SalesDate != null),
Pending = g.Count(x => x.ExpectedDate != null),
FirstReleaseDate = g.Min(x => x.SalesDate),
LatestReleaseDate = g.Max(x => x.SalesDate)
};
// Join circles with aggregates and latest product id
var baseQuery =
from c in context.Circles.AsNoTracking()
join agg in aggregates on c.CircleId equals agg.CircleId into aggs
from a in aggs.DefaultIfEmpty()
join lp in latestPerCircle on c.CircleId equals lp.CircleId into lps
from l in lps.DefaultIfEmpty()
select new CircleSearchItem
{
CircleId = c.CircleId,
Name = c.Name,
MakerId = c.MakerId,
Favorite = c.Favorite,
Blacklisted = c.Blacklisted,
Spam = c.Spam,
Downloads = a != null ? a.Downloads : 0,
Releases = a != null ? a.Releases : 0,
Pending = a != null ? a.Pending : 0,
FirstReleaseDate = a != null ? a.FirstReleaseDate : null,
LatestReleaseDate = a != null ? a.LatestReleaseDate : null,
LatestProductId = l != null ? l.LatestProductId : null,
// these two get filled in during Select stage (below)
LatestVoiceWorkHasImage = null,
LatestVoiceWorkSalesDate = null
};
return baseQuery;
}
protected override IQueryable<CircleSearchItem> ApplyFilters(IQueryable<CircleSearchItem> query, CircleSearchCriteria criteria)
{
if (!string.IsNullOrWhiteSpace(criteria.Name))
{
var term = $"%{criteria.Name.Trim()}%";
query = query.Where(x =>
EF.Functions.Like(x.Name, term) ||
EF.Functions.Like(x.MakerId, term));
}
//if (criteria.Status is CircleStatus.NotBlacklisted)
// query = query.Where(x => !x.Blacklisted);
//else if (criteria.Status is CircleStatus.Favorited)
// query = query.Where(x => x.Favorite);
//else if (criteria.Status is CircleStatus.Blacklisted)
// query = query.Where(x => x.Blacklisted);
//else if (criteria.Status is CircleStatus.Spam)
// query = query.Where(x => x.Spam);
return query;
}
protected override Expression<Func<CircleSearchItem, object>> GetSortExpression(CircleSortField field) => field switch
{
//CircleSortField.MakerId => x => x.MakerId,
//CircleSortField.Downloads => x => x.Downloads,
//CircleSortField.Releases => x => x.Releases,
//CircleSortField.Pending => x => x.Pending,
//CircleSortField.FirstReleaseDate => x => x.FirstReleaseDate ?? DateTime.MinValue,
//CircleSortField.LatestReleaseDate => x => x.LatestReleaseDate ?? DateTime.MinValue,
CircleSortField.Favorite => x => x.Favorite,
CircleSortField.Blacklisted => x => x.Blacklisted,
CircleSortField.Spam => x => x.Spam,
_ => x => x.Name
};
protected override IOrderedQueryable<CircleSearchItem> GetDefaultSortExpression(IQueryable<CircleSearchItem> query)
=> query.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
protected override IOrderedQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleSearchItem> query)
{
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
var selected =
from item in query
join vw in context.VoiceWorks.AsNoTracking() on item.LatestProductId equals vw.ProductId into vws
from latest in vws.DefaultIfEmpty()
select new CircleSearchItem
{
CircleId = item.CircleId,
Name = item.Name,
MakerId = item.MakerId,
Favorite = item.Favorite,
Blacklisted = item.Blacklisted,
Spam = item.Spam,
Downloads = item.Downloads,
Releases = item.Releases,
Pending = item.Pending,
FirstReleaseDate = item.FirstReleaseDate,
LatestReleaseDate = item.LatestReleaseDate,
LatestProductId = item.LatestProductId,
LatestVoiceWorkHasImage = latest != null ? latest.HasImage : (bool?)null,
LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
};
// Preserve existing ordering; add stable tiebreaker
return selected.OrderBy(x => 0).ThenBy(x => x.Name).ThenBy(x => x.CircleId);
// NOTE: If your base class re-applies ordering after Select, you can just:
// return selected.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
}
}

View File

@@ -0,0 +1,33 @@
using JSMR.Application.Circles.Queries.GetTags;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleTagsProvider(AppDbContext context) : ICircleTagsProvider
{
public async Task<GetCircleTagsResponse> HandleAsync(GetCircleTagsRequest request, CancellationToken cancellationToken = default)
{
int? circleId = await CircleLookup.ResolveCircleIdAsync(context, request.CircleId, request.NameOrMakerId, cancellationToken);
if (circleId is null)
return new GetCircleTagsResponse([]);
IQueryable<CircleTagItem> query =
from voiceWork in context.VoiceWorks.AsNoTracking()
where voiceWork.CircleId == circleId.Value
join voiceWorkTag in context.VoiceWorkTags.AsNoTracking() on voiceWork.VoiceWorkId equals voiceWorkTag.VoiceWorkId
join tag in context.Tags.AsNoTracking() on voiceWorkTag.TagId equals tag.TagId
join englishTag in context.EnglishTags.AsNoTracking() on tag.TagId equals englishTag.TagId into eng
from e in eng.DefaultIfEmpty()
group new { tag, e } by new { tag.Name, English = e != null ? e.Name : null } into g
orderby g.Count() descending
select new CircleTagItem(
g.Key.Name,
g.Key.English,
g.Count());
CircleTagItem[] items = await query.ToArrayAsync(cancellationToken);
return new GetCircleTagsResponse(items);
}
}

View File

@@ -0,0 +1,49 @@
using JSMR.Application.Circles.Commands.UpdateCircleStatus;
using JSMR.Application.Circles.Contracts;
using JSMR.Application.Circles.Ports;
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleWriter(AppDbContext context) : ICircleWriter
{
public async Task<UpdateCircleStatusResponse> UpdateStatusAsync(UpdateCircleStatusRequest request, CancellationToken cancellationToken = default)
{
Circle circle = await GetCircleAsync(request.CircleId, cancellationToken);
switch (request.CircleStatus)
{
case CircleStatus.Neutral:
circle.Favorite = false;
circle.Blacklisted = false;
circle.Spam = false;
break;
case CircleStatus.Favorite:
circle.Favorite = true;
circle.Blacklisted = false;
circle.Spam = false;
break;
case CircleStatus.Blacklisted:
circle.Favorite = false;
circle.Blacklisted = true;
circle.Spam = false;
break;
case CircleStatus.Spam:
circle.Favorite = false;
circle.Blacklisted = false;
circle.Spam = true;
break;
}
await context.SaveChangesAsync(cancellationToken);
return new UpdateCircleStatusResponse(request.CircleId, request.CircleStatus);
}
private async Task<Circle> GetCircleAsync(int circleId, CancellationToken cancellationToken)
{
return await context.Circles.FirstOrDefaultAsync(circle => circle.CircleId == circleId, cancellationToken)
?? throw new KeyNotFoundException($"Circle {circleId} not found.");
}
}

View File

@@ -0,0 +1,61 @@
using JSMR.Application.Creators.Queries.Search.Contracts;
using JSMR.Application.Creators.Queries.Search.Ports;
using JSMR.Infrastructure.Common.Queries;
using System.Linq.Expressions;
namespace JSMR.Infrastructure.Data.Repositories.Creators;
public class CreatorSearchProvider(AppDbContext context) : SearchProvider<CreatorSearchItem, CreatorSearchCriteria, CreatorSortField, CreatorSearchItem>, ICreatorSearchProvider
{
protected override IQueryable<CreatorSearchItem> GetBaseQuery()
{
return
from creator in context.Creators
join voiceWorkCreators in context.VoiceWorkCreators on creator.CreatorId equals voiceWorkCreators.CreatorId into vwcs
select new CreatorSearchItem
{
CreatorId = creator.CreatorId,
Name = creator.Name,
Favorite = creator.Favorite,
Blacklisted = creator.Blacklisted,
VoiceWorkCount = vwcs.Count()
};
}
protected override IQueryable<CreatorSearchItem> ApplyFilters(IQueryable<CreatorSearchItem> query, CreatorSearchCriteria criteria)
{
IQueryable<CreatorSearchItem> filteredQuery = query;
if (string.IsNullOrEmpty(criteria.Name) == false)
{
string name = criteria.Name.Trim();
filteredQuery = filteredQuery.Where(x => x.Name.Contains(name));
}
return filteredQuery;
}
protected override Expression<Func<CreatorSearchItem, object>> GetSortExpression(CreatorSortField field)
{
Expression<Func<CreatorSearchItem, object>> selector = field switch
{
CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount,
CreatorSortField.Favorite => x => x.Favorite,
CreatorSortField.Blacklisted => x => x.Blacklisted,
_ => x => x.Name
};
return selector;
}
protected override IOrderedQueryable<CreatorSearchItem> GetDefaultSortExpression(IQueryable<CreatorSearchItem> query)
{
return query.OrderBy(x => x.Name);
}
protected override IOrderedQueryable<CreatorSearchItem> GetSelectQuery(IOrderedQueryable<CreatorSearchItem> query)
{
return query;
}
}

View File

@@ -0,0 +1,41 @@
using JSMR.Application.Creators.Commands.UpdateCreatorStatus;
using JSMR.Application.Creators.Contracts;
using JSMR.Application.Creators.Ports;
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Creators;
public class CreatorWriter(AppDbContext context) : ICreatorWriter
{
public async Task<UpdateCreatorStatusResponse> UpdateStatusAsync(UpdateCreatorStatusRequest request, CancellationToken cancellationToken = default)
{
Creator creator = await GetCreatorAsync(request.CreatorId, cancellationToken);
switch (request.CreatorStatus)
{
case CreatorStatus.Neutral:
creator.Favorite = false;
creator.Blacklisted = false;
break;
case CreatorStatus.Favorite:
creator.Favorite = true;
creator.Blacklisted = false;
break;
case CreatorStatus.Blacklisted:
creator.Favorite = false;
creator.Blacklisted = true;
break;
}
await context.SaveChangesAsync(cancellationToken);
return new UpdateCreatorStatusResponse(request.CreatorId, request.CreatorStatus);
}
private async Task<Creator> GetCreatorAsync(int creatorId, CancellationToken cancellationToken)
{
return await context.Creators.FirstOrDefaultAsync(creator => creator.CreatorId == creatorId, cancellationToken)
?? throw new KeyNotFoundException($"Creator {creatorId} not found.");
}
}

View File

@@ -0,0 +1,94 @@
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Ports;
using JSMR.Infrastructure.Common.Queries;
using System.Linq.Expressions;
namespace JSMR.Infrastructure.Data.Repositories.Tags;
//public class TagReader(AppDbContext context) : ITagReader
//{
// public async Task<TagSearchResults> SearchAsync(TagSearchOptions options, CancellationToken cancellationToken = default)
// {
// IQueryable<TagSearchItem> baseQuery = GetQuery();
// IQueryable<TagSearchItem> filteredQuery = ApplyFilters(baseQuery, options);
// int total = await filteredQuery.CountAsync(cancellationToken);
// IOrderedQueryable<TagSearchItem> orderedQuery = ApplySorting(filteredQuery, options);
// TagSearchItem[] items = await orderedQuery
// .Skip((options.PageNumber - 1) * options.PageSize)
// .Take(options.PageSize)
// .ToArrayAsync(cancellationToken);
// return new TagSearchResults()
// {
// Items = items,
// TotalItems = total
// };
// }
// private IQueryable<TagSearchItem> GetQuery()
// {
// return
// from tag in context.Tags
// join englishTag in context.EnglishTags on tag.TagId equals englishTag.TagId into test
// from subEnglishTag in test.DefaultIfEmpty()
// join voiceWorkTags in context.VoiceWorkTags on tag.TagId equals voiceWorkTags.TagId into vwts
// select new TagSearchItem
// {
// TagId = tag.TagId,
// Name = tag.Name,
// EnglishName = subEnglishTag.Name,
// Favorite = tag.Favorite,
// Blacklisted = tag.Blacklisted,
// VoiceWorkCount = context.VoiceWorkTags.Count(x => x.TagId == tag.TagId)
// };
// }
// private static IQueryable<TagSearchItem> ApplyFilters(IQueryable<TagSearchItem> query, TagSearchOptions options)
// {
// IQueryable<TagSearchItem> filteredQuery = query;
// if (string.IsNullOrEmpty(options.Criteria.Name) == false)
// {
// string name = options.Criteria.Name.Trim();
// filteredQuery = filteredQuery.Where(x =>
// x.Name.Contains(name) || (x.EnglishName != null && x.EnglishName.Contains(name)));
// }
// return filteredQuery;
// }
// private static IOrderedQueryable<TagSearchItem> ApplySorting(IQueryable<TagSearchItem> query, TagSearchOptions options)
// {
// IOrderedQueryable<TagSearchItem>? ordered = null;
// for (int i = 0; i < options.SortOptions.Length; i++)
// {
// var (field, direction) = (options.SortOptions[i].Field, options.SortOptions[i].Direction);
// bool isDescending = direction == SortDirection.Descending;
// IOrderedQueryable<TagSearchItem> applyFirst(Expression<Func<TagSearchItem, object>> selector) => isDescending ? query.OrderByDescending(selector) : query.OrderBy(selector);
// IOrderedQueryable<TagSearchItem> applyNext(Expression<Func<TagSearchItem, object>> selector) => isDescending ? ordered!.ThenByDescending(selector) : ordered!.ThenBy(selector);
// Expression<Func<TagSearchItem, object>> selector = field switch
// {
// TagSortField.EnglishName => x => x.EnglishName ?? "",
// TagSortField.VoiceWorkCount => x => x.VoiceWorkCount,
// TagSortField.Favorite => x => x.Favorite,
// TagSortField.Blacklisted => x => x.Blacklisted,
// _ => x => x.Name
// };
// ordered = (i == 0) ? applyFirst(selector) : applyNext(selector);
// }
// ordered ??= query.OrderBy(x => x.Name);
// return ordered;
// }
//}

View File

@@ -0,0 +1,66 @@
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Ports;
using JSMR.Infrastructure.Common.Queries;
using System.Linq.Expressions;
namespace JSMR.Infrastructure.Data.Repositories.Tags;
public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchItem, TagSearchCriteria, TagSortField, TagSearchItem>, ITagSearchProvider
{
protected override IQueryable<TagSearchItem> GetBaseQuery()
{
return
from tag in context.Tags
join englishTag in context.EnglishTags on tag.TagId equals englishTag.TagId into test
from subEnglishTag in test.DefaultIfEmpty()
join voiceWorkTags in context.VoiceWorkTags on tag.TagId equals voiceWorkTags.TagId into vwts
select new TagSearchItem
{
TagId = tag.TagId,
Name = tag.Name,
EnglishName = subEnglishTag.Name,
Favorite = tag.Favorite,
Blacklisted = tag.Blacklisted,
VoiceWorkCount = vwts.Count()
};
}
protected override IQueryable<TagSearchItem> ApplyFilters(IQueryable<TagSearchItem> query, TagSearchCriteria criteria)
{
IQueryable<TagSearchItem> filteredQuery = query;
if (string.IsNullOrEmpty(criteria.Name) == false)
{
string name = criteria.Name.Trim();
filteredQuery = filteredQuery.Where(x =>
x.Name.Contains(name) || (x.EnglishName != null && x.EnglishName.Contains(name)));
}
return filteredQuery;
}
protected override Expression<Func<TagSearchItem, object>> GetSortExpression(TagSortField field)
{
Expression<Func<TagSearchItem, object>> selector = field switch
{
TagSortField.EnglishName => x => x.EnglishName ?? "",
TagSortField.VoiceWorkCount => x => x.VoiceWorkCount,
TagSortField.Favorite => x => x.Favorite,
TagSortField.Blacklisted => x => x.Blacklisted,
_ => x => x.Name
};
return selector;
}
protected override IOrderedQueryable<TagSearchItem> GetDefaultSortExpression(IQueryable<TagSearchItem> query)
{
return query.OrderBy(x => x.Name);
}
protected override IOrderedQueryable<TagSearchItem> GetSelectQuery(IOrderedQueryable<TagSearchItem> query)
{
return query;
}
}

View File

@@ -0,0 +1,67 @@
using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Contracts;
using JSMR.Application.Tags.Ports;
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Tags;
public class TagWriter(AppDbContext context) : ITagWriter
{
public async Task<SetTagEnglishNameResponse> SetEnglishNameAsync(SetTagEnglishNameRequest request, CancellationToken cancellationToken = default)
{
Tag tag = await GetTagAsync(request.TagId, cancellationToken);
EnglishTag? englishTag = await context.EnglishTags.FirstOrDefaultAsync(e => e.TagId == request.TagId, cancellationToken);
if (englishTag is null)
{
englishTag = new EnglishTag
{
TagId = request.TagId,
Name = request.EnglishName
};
context.EnglishTags.Add(englishTag);
}
else
{
englishTag.Name = request.EnglishName;
}
await context.SaveChangesAsync(cancellationToken);
return new SetTagEnglishNameResponse(request.TagId, request.EnglishName);
}
public async Task<UpdateTagStatusResponse> UpdateStatusAsync(UpdateTagStatusRequest request, CancellationToken cancellationToken = default)
{
Tag tag = await GetTagAsync(request.TagId, cancellationToken);
switch (request.TagStatus)
{
case TagStatus.Neutral:
tag.Favorite = false;
tag.Blacklisted = false;
break;
case TagStatus.Favorite:
tag.Favorite = true;
tag.Blacklisted = false;
break;
case TagStatus.Blacklisted:
tag.Favorite = false;
tag.Blacklisted = true;
break;
}
await context.SaveChangesAsync(cancellationToken);
return new UpdateTagStatusResponse(request.TagId, request.TagStatus);
}
private async Task<Tag> GetTagAsync(int tagId, CancellationToken cancellationToken)
{
return await context.Tags.FirstOrDefaultAsync(tag => tag.TagId == tagId, cancellationToken)
?? throw new KeyNotFoundException($"Tag {tagId} not found.");
}
}