Compare commits

...

38 Commits

Author SHA1 Message Date
53f1df780c Updated packages.
All checks were successful
ci / build-test (push) Successful in 2m43s
ci / publish-image (push) Successful in 2m46s
2026-05-14 10:20:38 -04:00
53ec67f99d Updated command timeout length to 120 seconds for worker and API. 2026-05-14 10:19:43 -04:00
0ed3bc6298 Updated voice work delete logic + tests. 2026-05-14 10:19:06 -04:00
8a13f282b1 Updated packages.
Some checks failed
ci / publish-image (push) Blocked by required conditions
ci / build-test (push) Failing after 12m52s
2026-05-13 10:03:35 -04:00
5c27fb7f21 Updated login page background.
All checks were successful
ci / build-test (push) Successful in 2m33s
ci / publish-image (push) Successful in 1m51s
2026-05-10 20:58:57 -04:00
06d5aa345d Finalized delete voice work logic.
All checks were successful
ci / build-test (push) Successful in 2m45s
ci / publish-image (push) Successful in 1m54s
2026-05-09 22:19:44 -04:00
5eecba7eec Updated delete logic for voice works.
All checks were successful
ci / build-test (push) Successful in 3m1s
ci / publish-image (push) Successful in 1m58s
2026-05-09 00:51:10 -04:00
9c9e33ebec Added initial voice work edit logic (set favorite / delete) on Api and UI layers.
All checks were successful
ci / build-test (push) Successful in 2m44s
ci / publish-image (push) Successful in 1m45s
2026-05-07 00:07:20 -04:00
2bd7e3b970 Updated login page.
All checks were successful
ci / build-test (push) Successful in 2m29s
ci / publish-image (push) Successful in 1m44s
2026-05-04 01:52:35 -04:00
abcc82437f Updated logic for getting released work information (take 60 day period max limit into consideration). 2026-05-04 01:52:25 -04:00
f6674e0382 Added "Delete Voice Works" functionality. 2026-05-04 01:51:40 -04:00
77a02a543d Added tag filter state persistence.
All checks were successful
ci / build-test (push) Successful in 2m26s
ci / publish-image (push) Successful in 1m32s
2026-04-26 23:39:47 -04:00
2355d7fe65 Added front-end "Set English Tag Name" logic.
All checks were successful
ci / build-test (push) Successful in 2m27s
ci / publish-image (push) Successful in 1m27s
2026-04-26 00:40:30 -04:00
204e186354 Added tag/creator status update functionality on the API and UI layers.
All checks were successful
ci / build-test (push) Successful in 2m29s
ci / publish-image (push) Successful in 1m42s
2026-04-25 14:17:13 -04:00
dbed9fc905 Updated packages.
All checks were successful
ci / build-test (push) Successful in 2m32s
ci / publish-image (push) Successful in 1m32s
2026-04-25 10:19:44 -04:00
d6a4015c91 Added logging to scanner.
All checks were successful
ci / build-test (push) Successful in 2m29s
ci / publish-image (push) Successful in 1m32s
2026-04-24 10:18:26 -04:00
b63a89c8be Added new pagination component. 2026-04-24 10:18:16 -04:00
dfa840d816 Updated packages.
All checks were successful
ci / build-test (push) Successful in 2m35s
ci / publish-image (push) Successful in 2m44s
2026-04-21 20:12:25 -04:00
6bc91b293d Updated tags/creators page styling.
All checks were successful
ci / build-test (push) Successful in 2m30s
ci / publish-image (push) Successful in 1m42s
2026-04-21 00:41:31 -04:00
da33973229 Updated tags and creators pages.
All checks were successful
ci / build-test (push) Successful in 2m30s
ci / publish-image (push) Successful in 1m46s
2026-04-20 00:36:53 -04:00
c203b2cbdb Updated Blazor UI tag/creator views. Altered logic for sorting favorite/blacklisted fields for tags/creators.
All checks were successful
ci / build-test (push) Successful in 2m45s
ci / publish-image (push) Successful in 1m58s
2026-04-18 21:39:58 -04:00
1f91e46527 Improved performance of voice work search updater.
All checks were successful
ci / build-test (push) Successful in 2m56s
ci / publish-image (push) Successful in 2m15s
2026-04-17 10:29:23 -04:00
be466b52e0 Updated packages.
All checks were successful
ci / build-test (push) Successful in 4m45s
ci / publish-image (push) Successful in 3m0s
2026-04-15 09:55:57 -04:00
b4863a9edf Updated packages.
Some checks failed
ci / build-test (push) Failing after 3m34s
ci / publish-image (push) Has been skipped
2026-04-14 20:52:05 -04:00
b13340061f Fixed duplicate tag/creator upsert issue.
Some checks failed
ci / build-test (push) Failing after 13m47s
ci / publish-image (push) Has been cancelled
2026-04-13 20:15:13 -04:00
85a28a6017 Updates various packages.
All checks were successful
ci / build-test (push) Successful in 2m25s
ci / publish-image (push) Successful in 3m5s
2026-04-12 12:06:01 -04:00
45a8c8be5a Added voice work repository tests.
All checks were successful
ci / build-test (push) Successful in 2m21s
ci / publish-image (push) Successful in 1m39s
2026-03-31 00:43:02 -04:00
347f6f297d If the ingest comes in with zero supported language, do not make any changes, specifically removals.
All checks were successful
ci / build-test (push) Successful in 2m23s
ci / publish-image (push) Successful in 1m37s
2026-03-30 23:22:50 -04:00
adfbf654a6 Added logic to remove supported languages that are no longer supported, rather than just being purely additive. Added ApiClient logging.
All checks were successful
ci / build-test (push) Successful in 2m30s
ci / publish-image (push) Successful in 2m1s
2026-03-30 23:03:53 -04:00
0dd11e6351 Added English localization logic during the regular scan process.
All checks were successful
ci / build-test (push) Successful in 2m17s
ci / publish-image (push) Successful in 1m41s
2026-03-29 21:24:04 -04:00
d9e421178f Added inital job entity and services. Added released works API integration.
All checks were successful
ci / build-test (push) Successful in 2m21s
ci / publish-image (push) Successful in 2m19s
2026-03-27 01:32:39 -04:00
1c016ac62e Undid favorite/blacklisted tag color-coded stylings.
All checks were successful
ci / build-test (push) Successful in 2m44s
ci / publish-image (push) Successful in 1m32s
2026-03-18 00:27:43 -04:00
ce9fbe491d Fixed "Filter Language" test.
All checks were successful
ci / build-test (push) Successful in 2m19s
ci / publish-image (push) Successful in 1m49s
2026-03-17 00:31:08 -04:00
22d5a261c5 Fixed voice work supported language search. Include supported languages and original circle in the search result item. Several UI style updates.
Some checks failed
ci / build-test (push) Failing after 2m52s
ci / publish-image (push) Has been skipped
2026-03-17 00:07:02 -04:00
c8403e0e21 Updated various packages.
All checks were successful
ci / build-test (push) Successful in 2m18s
ci / publish-image (push) Successful in 2m2s
2026-03-14 22:42:53 -04:00
a45f08fe6d Merge pull request 'Chobit Integration' (#2) from feature/issue-1-chobit-integration into master
All checks were successful
ci / build-test (push) Successful in 2m34s
ci / publish-image (push) Successful in 2m49s
Reviewed-on: #2
2026-03-15 02:34:11 +00:00
928e69b2ec Updated launch settings for debugging, and removed redundant project reference.
All checks were successful
ci / build-test (pull_request) Successful in 2m35s
ci / publish-image (pull_request) Has been skipped
2026-03-14 22:24:21 -04:00
aab7bee694 Added Chobit integration. Updated tests.
All checks were successful
ci / build-test (push) Successful in 2m27s
ci / publish-image (push) Has been skipped
2026-03-14 21:46:53 -04:00
130 changed files with 9068 additions and 516 deletions

View File

@@ -8,11 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -22,7 +22,10 @@ public static class ServiceCollectionExtensions
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb"); ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
services.AddDbContextFactory<AppDbContext>(opt => services.AddDbContextFactory<AppDbContext>(opt =>
opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)) opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), mySqlOptions =>
{
mySqlOptions.CommandTimeout(120);
})
.EnableSensitiveDataLogging(false)); .EnableSensitiveDataLogging(false));
services.AddControllers(); services.AddControllers();

View File

@@ -1,7 +1,12 @@
using JSMR.Application.Circles.Queries.Search; using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Creators.Commands.UpdateCreatorStatus;
using JSMR.Application.Creators.Queries.Search; using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Queries.Search; using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.Users; using JSMR.Application.Users;
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -48,6 +53,23 @@ public static class WebApplicationExtensions
} }
}); });
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await Results.Problem(
title: "An unexpected error occurred.",
detail: app.Environment.IsDevelopment()
? "Check the API logs for details."
: null,
statusCode: StatusCodes.Status500InternalServerError
).ExecuteAsync(context);
});
});
return app; return app;
} }
@@ -56,6 +78,9 @@ public static class WebApplicationExtensions
app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapSearchEndpoints(); app.MapSearchEndpoints();
app.MapVoiceWorkCommandEndpoints();
app.MapTagCommandEndpoints();
app.MapCreatorCommandEndpoints();
app.MapAuthenticationEndpoints(); app.MapAuthenticationEndpoints();
} }
@@ -105,6 +130,60 @@ public static class WebApplicationExtensions
}); });
} }
private static void MapVoiceWorkCommandEndpoints(this WebApplication app)
{
app.MapPost("/api/voicework/set-favorite", async (
SetVoiceWorkFavoriteRequest request,
SetVoiceWorkFavoriteHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/voicework/delete", async (
DeleteVoiceWorkRequest request,
DeleteVoiceWorkHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapTagCommandEndpoints(this WebApplication app)
{
app.MapPost("/api/tags/update-status", async (
UpdateTagStatusRequest request,
UpdateTagStatusHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/tags/set-english-name", async (
SetTagEnglishNameRequest request,
SetTagEnglishNameHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapCreatorCommandEndpoints(this WebApplication app)
{
app.MapPost("/api/creators/update-status", async (
UpdateCreatorStatusRequest request,
UpdateCreatorStatusHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapAuthenticationEndpoints(this WebApplication app) private static void MapAuthenticationEndpoints(this WebApplication app)
{ {
app.MapPost("/auth/login", async (LoginRequest req, IUserRepository users, HttpContext http) => app.MapPost("/auth/login", async (LoginRequest req, IUserRepository users, HttpContext http) =>

View File

@@ -2,9 +2,9 @@
public record CreatorSearchItem public record CreatorSearchItem
{ {
public int CreatorId { get; init; } public int CreatorId { get; set; }
public required string Name { get; init; } public required string Name { get; set; }
public bool Favorite { get; init; } public bool Favorite { get; set; }
public bool Blacklisted { get; init; } public bool Blacklisted { get; set; }
public int VoiceWorkCount { get; init; } public int VoiceWorkCount { get; set; }
} }

View File

@@ -1,9 +1,12 @@
using JSMR.Application.Circles.Queries.Search; using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Creators.Commands.UpdateCreatorStatus;
using JSMR.Application.Creators.Queries.Search; using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Scanning; using JSMR.Application.Scanning;
using JSMR.Application.Tags.Commands.SetEnglishName; using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus; using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Queries.Search; using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -17,6 +20,9 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<SearchCirclesHandler>(); services.AddScoped<SearchCirclesHandler>();
services.AddScoped<SearchVoiceWorksHandler>(); services.AddScoped<SearchVoiceWorksHandler>();
services.AddScoped<SetVoiceWorkFavoriteHandler>();
services.AddScoped<DeleteVoiceWorkHandler>();
services.AddScoped<ScanVoiceWorksHandler>(); services.AddScoped<ScanVoiceWorksHandler>();
services.AddScoped<SearchTagsHandler>(); services.AddScoped<SearchTagsHandler>();
@@ -24,6 +30,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<UpdateTagStatusHandler>(); services.AddScoped<UpdateTagStatusHandler>();
services.AddScoped<SearchCreatorsHandler>(); services.AddScoped<SearchCreatorsHandler>();
services.AddScoped<UpdateCreatorStatusHandler>();
return services; return services;
} }

View File

@@ -1,7 +1,12 @@
namespace JSMR.Application.Integrations.Chobit.Models; using System.Text.Json.Serialization;
namespace JSMR.Application.Integrations.Chobit.Models;
public class ChobitResult public class ChobitResult
{ {
[JsonPropertyName("count")]
public int Count { get; set; } public int Count { get; set; }
[JsonPropertyName("works")]
public ChobitWork[] Works { get; set; } = []; public ChobitWork[] Works { get; set; } = [];
} }

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.Integrations.Chobit.Models;
public class ChobitResultCollection : Dictionary<string, ChobitResult> { }

View File

@@ -1,16 +1,39 @@
namespace JSMR.Application.Integrations.Chobit.Models; using System.Text.Json.Serialization;
namespace JSMR.Application.Integrations.Chobit.Models;
public class ChobitWork public class ChobitWork
{ {
[JsonPropertyName("work_id")]
public string? WorkId { get; set; } public string? WorkId { get; set; }
[JsonPropertyName("dlsite_work_id")]
public string? DLSiteWorkId { get; set; } public string? DLSiteWorkId { get; set; }
[JsonPropertyName("work_name")]
public string? WorkName { get; set; } public string? WorkName { get; set; }
[JsonPropertyName("work_name_kana")]
public string? WorkNameKana { get; set; } public string? WorkNameKana { get; set; }
public string? URL { get; set; }
public string? EmbedURL { get; set; } [JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("embed_url")]
public string? EmbedUrl { get; set; }
[JsonPropertyName("thumb")]
public string? Thumb { get; set; } public string? Thumb { get; set; }
[JsonPropertyName("mini_thumb")]
public string? MiniThumb { get; set; } public string? MiniThumb { get; set; }
[JsonPropertyName("file_type")]
public string? FileType { get; set; } public string? FileType { get; set; }
[JsonPropertyName("embed_width")]
public decimal EmbedWidth { get; set; } public decimal EmbedWidth { get; set; }
[JsonPropertyName("embed_height")]
public decimal EmbedHeight { get; set; } public decimal EmbedHeight { get; set; }
} }

View File

@@ -1,3 +0,0 @@
namespace JSMR.Application.Integrations.Chobit.Models;
public class ChobitWorkResult : Dictionary<string, ChobitResult> { }

View File

@@ -0,0 +1,9 @@
using JSMR.Application.Integrations.Chobit.Models;
namespace JSMR.Application.Integrations.Chobit.Ports;
public interface IChobitClient
{
Task<ChobitResult> GetSampleInfoAsync(string productId, CancellationToken cancellationToken = default);
Task<ChobitResultCollection> GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,6 @@
using JSMR.Application.Integrations.Cien.Models; using JSMR.Application.Integrations.Cien.Models;
namespace JSMR.Application.Integrations.Ports; namespace JSMR.Application.Integrations.Cien.Ports;
public interface ICienClient public interface ICienClient
{ {

View File

@@ -0,0 +1,10 @@
namespace JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
public record ReleasedWork
{
public required string ProductId { get; init; }
public required string Title { get; init; }
public required string MaskedTitle { get; init; }
public required string Description { get; init; }
public required string MaskedDescription { get; init; }
}

View File

@@ -0,0 +1,9 @@
using JSMR.Application.Enums;
namespace JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
public record ReleasedWorksRequest(
Locale Locale,
DateOnly Date,
int Period
);

View File

@@ -0,0 +1,6 @@
namespace JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
public class ReleasedWorksCollection : Dictionary<string, ReleasedWork>
{
}

View File

@@ -0,0 +1,10 @@
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
namespace JSMR.Application.Integrations.DLSite.Ports;
public interface IDLSiteClient
{
Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default);
Task<ReleasedWorksCollection> GetReleasedWorksAsync(ReleasedWorksRequest request, CancellationToken cancellationToken);
}

View File

@@ -1,8 +0,0 @@
using JSMR.Application.Integrations.Chobit.Models;
namespace JSMR.Application.Integrations.Ports;
public interface IChobitClient
{
Task<ChobitWorkResult> GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default);
}

View File

@@ -1,8 +0,0 @@
using JSMR.Application.Integrations.DLSite.Models;
namespace JSMR.Application.Integrations.Ports;
public interface IDLSiteClient
{
Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default);
}

View File

@@ -11,8 +11,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,10 @@
namespace JSMR.Application.Jobs;
public interface IJobProgressWriter
{
Task SetStepAsync(int jobId, string step, CancellationToken cancellationToken);
Task SetProgressAsync(int jobId, int? current, int? total, CancellationToken cancellationToken);
Task SetHeartbeatAsync(int jobId, CancellationToken cancellationToken);
Task CompleteAsync(int jobId, string? summary, CancellationToken cancellationToken);
Task FailAsync(int jobId, string error, CancellationToken cancellationTokenct);
}

View File

@@ -0,0 +1,15 @@
using JSMR.Domain.Entities;
namespace JSMR.Application.Jobs;
public interface IJobRepository
{
Task<Job> AddAsync(Job job, CancellationToken cancellationToken);
Task<Job?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<Job>> GetRecentAsync(int take, CancellationToken cancellationToken);
Task<bool> AnyRunningAsync(CancellationToken cancellationToken);
Task<Job?> TryClaimNextQueuedAsync(string workerName, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -4,11 +4,8 @@ namespace JSMR.Application.Scanning.Contracts;
public class DLSiteWork public class DLSiteWork
{ {
//public DLSiteWorkType Type { get; set; }
//public DLSiteWorkCategory Category { get; set; }
public required string ProductName { get; set; } public required string ProductName { get; set; }
public required string ProductId { get; set; } public required string ProductId { get; set; }
//public DateOnly? AnnouncedDate { get; set; }
public DateOnly? ExpectedDate { get; set; } public DateOnly? ExpectedDate { get; set; }
public DateOnly? SalesDate { get; set; } public DateOnly? SalesDate { get; set; }
public int Downloads { get; set; } public int Downloads { get; set; }

View File

@@ -1,4 +1,5 @@
using JSMR.Application.Integrations.DLSite.Models; using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Domain.Enums; using JSMR.Domain.Enums;
using JSMR.Domain.ValueObjects; using JSMR.Domain.ValueObjects;
@@ -16,7 +17,7 @@ public sealed record VoiceWorkIngest
public int WishlistCount { get; init; } public int WishlistCount { get; init; }
public int Downloads { get; init; } public int Downloads { get; init; }
public bool HasTrial { get; init; } public bool HasTrial { get; init; }
public bool HasDLPlay { get; init; } public bool HasChobit { get; init; }
public byte? StarRating { get; init; } public byte? StarRating { get; init; }
public int? Votes { get; init; } public int? Votes { get; init; }
public AgeRating AgeRating { get; init; } public AgeRating AgeRating { get; init; }
@@ -28,8 +29,9 @@ public sealed record VoiceWorkIngest
public AIGeneration AI { get; init; } public AIGeneration AI { get; init; }
public VoiceWorkSeries? Series { get; init; } public VoiceWorkSeries? Series { get; init; }
public VoiceWorkTranslation? Translation { get; init; } public VoiceWorkTranslation? Translation { get; init; }
public VoiceWorkLocalizationIngest[] Localizations { get; init; } = [];
public static VoiceWorkIngest From(DLSiteWork work, VoiceWorkDetails? details) public static VoiceWorkIngest From(DLSiteWork work, VoiceWorkDetails? details, ChobitResult? chobit)
{ {
return new VoiceWorkIngest() return new VoiceWorkIngest()
{ {
@@ -43,7 +45,7 @@ public sealed record VoiceWorkIngest
WishlistCount = details?.WishlistCount ?? 0, WishlistCount = details?.WishlistCount ?? 0,
Downloads = Math.Max(work.Downloads, details?.DownloadCount ?? 0), Downloads = Math.Max(work.Downloads, details?.DownloadCount ?? 0),
HasTrial = work.HasTrial || (details?.HasTrial ?? false), HasTrial = work.HasTrial || (details?.HasTrial ?? false),
HasDLPlay = details?.HasDLPlay ?? false, HasChobit = chobit?.Count > 0,
StarRating = work.StarRating, StarRating = work.StarRating,
Votes = work.Votes, Votes = work.Votes,
AgeRating = details?.AgeRating ?? work.AgeRating, AgeRating = details?.AgeRating ?? work.AgeRating,

View File

@@ -0,0 +1,10 @@
using JSMR.Domain.Enums;
namespace JSMR.Application.Scanning.Contracts;
public sealed class VoiceWorkLocalizationIngest
{
public Language Language { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
}

View File

@@ -0,0 +1,9 @@
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Scanning.Contracts;
namespace JSMR.Application.Scanning.Ports;
public interface IReleasedWorksProvider
{
Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,8 @@
using JSMR.Application.Scanning.Contracts;
namespace JSMR.Application.Scanning.Ports;
public interface IVoiceWorkIngestBuilder
{
Task<VoiceWorkIngest[]> BuildAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken);
}

View File

@@ -1,21 +1,43 @@
using JSMR.Application.Common.Caching; using JSMR.Application.Common.Caching;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.Ports;
using JSMR.Application.Scanning.Contracts; using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports; using JSMR.Application.Scanning.Ports;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace JSMR.Application.Scanning; namespace JSMR.Application.Scanning;
public sealed class ScanVoiceWorksHandler( public sealed class ScanVoiceWorksHandler(
ILogger<ScanVoiceWorksHandler> logger,
IVoiceWorkScannerRepository scannerRepository, IVoiceWorkScannerRepository scannerRepository,
IVoiceWorkUpdaterRepository updaterRepository, IVoiceWorkUpdaterRepository updaterRepository,
IDLSiteClient dlsiteClient,
ISpamCircleCache spamCircleCache, ISpamCircleCache spamCircleCache,
IVoiceWorkIngestBuilder ingestBuilder,
IVoiceWorkSearchUpdater searchUpdater) IVoiceWorkSearchUpdater searchUpdater)
{ {
public async Task<ScanVoiceWorksResponse> HandleAsync(ScanVoiceWorksRequest request, CancellationToken cancellationToken) public async Task<ScanVoiceWorksResponse> HandleAsync(ScanVoiceWorksRequest request, CancellationToken cancellationToken)
{ {
using IDisposable? scope = logger.BeginScope(new Dictionary<string, object>
{
["Locale"] = request.Locale,
["PageNumber"] = request.PageNumber,
["PageSize"] = request.PageSize
});
Stopwatch stopwatch = Stopwatch.StartNew();
string currentPhase = "initialization";
try
{
logger.LogInformation(
"Starting scan handler for page {PageNumber}, page size {PageSize}, locale {Locale}",
request.PageNumber,
request.PageSize,
request.Locale);
currentPhase = "resolve_scanner";
IVoiceWorksScanner? scanner = scannerRepository.GetScanner(request.Locale); IVoiceWorksScanner? scanner = scannerRepository.GetScanner(request.Locale);
currentPhase = "resolve_updater";
IVoiceWorkUpdater? updater = updaterRepository.GetUpdater(request.Locale); IVoiceWorkUpdater? updater = updaterRepository.GetUpdater(request.Locale);
if (scanner is null) if (scanner is null)
@@ -24,41 +46,118 @@ public sealed class ScanVoiceWorksHandler(
if (updater is null) if (updater is null)
throw new InvalidOperationException($"No updater registered for locale {request.Locale}."); throw new InvalidOperationException($"No updater registered for locale {request.Locale}.");
currentPhase = "load_spam_circle_cache";
var spamStopwatch = Stopwatch.StartNew();
string[] excludedMakerIds = await spamCircleCache.GetAsync(cancellationToken);
spamStopwatch.Stop();
logger.LogInformation(
"Loaded spam circle cache in {ElapsedMs} ms. ExcludedMakerCount={ExcludedMakerCount}",
spamStopwatch.ElapsedMilliseconds,
excludedMakerIds.Length);
VoiceWorkScanOptions options = new( VoiceWorkScanOptions options = new(
PageNumber: request.PageNumber, PageNumber: request.PageNumber,
PageSize: request.PageSize, PageSize: request.PageSize,
ExcludedMakerIds: await spamCircleCache.GetAsync(cancellationToken), ExcludedMakerIds: excludedMakerIds,
ExcludePartiallyAIGeneratedWorks: true, ExcludePartiallyAIGeneratedWorks: true,
ExcludeAIGeneratedWorks: true ExcludeAIGeneratedWorks: true
); );
currentPhase = "scan_page";
var scanStopwatch = Stopwatch.StartNew();
VoiceWorkScanResult scanResult = await scanner.ScanPageAsync(options, cancellationToken); VoiceWorkScanResult scanResult = await scanner.ScanPageAsync(options, cancellationToken);
scanStopwatch.Stop();
logger.LogInformation(
"Scanned source page in {ElapsedMs} ms. EndOfResults={EndOfResults}, ResultCount={ResultCount}",
scanStopwatch.ElapsedMilliseconds,
scanResult.EndOfResults,
scanResult.Works.Length);
if (scanResult.EndOfResults) if (scanResult.EndOfResults)
{ {
stopwatch.Stop();
logger.LogInformation(
"End of results reached for page {PageNumber}. TotalElapsedMs={ElapsedMs}",
request.PageNumber,
stopwatch.ElapsedMilliseconds);
return new ScanVoiceWorksResponse( return new ScanVoiceWorksResponse(
Results: [], Results: [],
EndOfResults: true EndOfResults: true
); );
} }
string[] productIds = [.. scanResult.Works.Where(x => !string.IsNullOrWhiteSpace(x.ProductId)).Select(x => x.ProductId!)]; currentPhase = "build_ingests";
VoiceWorkDetailCollection voiceWorkDetails = await dlsiteClient.GetVoiceWorkDetailsAsync(productIds, cancellationToken); var ingestSw = Stopwatch.StartNew();
VoiceWorkIngest[] ingests = [.. scanResult.Works.Select(work => VoiceWorkIngest[] ingests = await ingestBuilder.BuildAsync(scanResult, cancellationToken);
{
voiceWorkDetails.TryGetValue(work.ProductId!, out VoiceWorkDetails? value); ingestSw.Stop();
return VoiceWorkIngest.From(work, value); logger.LogInformation(
})]; "Built ingests in {ElapsedMs} ms. IngestCount={IngestCount}",
ingestSw.ElapsedMilliseconds,
ingests.Length);
currentPhase = "upsert_voiceworks";
var upsertSw = Stopwatch.StartNew();
VoiceWorkUpsertResult[] upsertResults = await updater.UpsertAsync(ingests, cancellationToken); VoiceWorkUpsertResult[] upsertResults = await updater.UpsertAsync(ingests, cancellationToken);
upsertSw.Stop();
int[] voiceWorkIds = [.. upsertResults.Where(x => x.VoiceWorkId.HasValue).Select(x => x.VoiceWorkId!.Value)]; int[] voiceWorkIds = [.. upsertResults.Where(x => x.VoiceWorkId.HasValue).Select(x => x.VoiceWorkId!.Value)];
int issueCount = upsertResults.Sum(x => x.Issues.Count);
logger.LogInformation(
"Upserted voice works in {ElapsedMs} ms. UpsertResultCount={UpsertResultCount}, VoiceWorkIdCount={VoiceWorkIdCount}, IssueCount={IssueCount}",
upsertSw.ElapsedMilliseconds,
upsertResults.Length,
voiceWorkIds.Length,
issueCount);
currentPhase = "update_search";
var searchSw = Stopwatch.StartNew();
await searchUpdater.UpdateAsync(voiceWorkIds, cancellationToken); await searchUpdater.UpdateAsync(voiceWorkIds, cancellationToken);
searchSw.Stop();
logger.LogInformation(
"Updated search index in {ElapsedMs} ms. VoiceWorkIdCount={VoiceWorkIdCount}",
searchSw.ElapsedMilliseconds,
voiceWorkIds.Length);
stopwatch.Stop();
logger.LogInformation(
"Completed scan handler for page {PageNumber} in {ElapsedMs} ms",
request.PageNumber,
stopwatch.ElapsedMilliseconds);
return new ScanVoiceWorksResponse( return new ScanVoiceWorksResponse(
Results: upsertResults, Results: upsertResults,
EndOfResults: false EndOfResults: false
); );
} }
catch (Exception ex)
{
stopwatch.Stop();
logger.LogError(
ex,
"Scan handler failed during phase {Phase} for page {PageNumber} after {ElapsedMs} ms",
currentPhase,
request.PageNumber,
stopwatch.ElapsedMilliseconds);
throw;
}
}
} }

View File

@@ -2,10 +2,10 @@
public record TagSearchItem public record TagSearchItem
{ {
public int TagId { get; init; } public int TagId { get; set; }
public required string Name { get; init; } public required string Name { get; set; }
public bool Favorite { get; init; } public bool Favorite { get; set; }
public bool Blacklisted { get; init; } public bool Blacklisted { get; set; }
public string? EnglishName { get; init; } public string? EnglishName { get; set; }
public int VoiceWorkCount { get; init; } public int VoiceWorkCount { get; set; }
} }

View File

@@ -0,0 +1,11 @@
using JSMR.Application.VoiceWorks.Ports;
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed class DeleteVoiceWorkHandler(IVoiceWorkWriter writer)
{
public async Task<DeleteVoiceWorkResponse> HandleAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken = default)
{
return await writer.DeleteAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed record DeleteVoiceWorkRequest(int[] VoiceWorkIds);

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed record DeleteVoiceWorkResponse(Dictionary<int, DeleteVoiceWorkStatus> Results);

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public enum DeleteVoiceWorkStatus
{
Deleted,
NotFound,
NotAllowed,
Failed
}

View File

@@ -1,8 +1,10 @@
using JSMR.Application.VoiceWorks.Commands.SetFavorite; using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
namespace JSMR.Application.VoiceWorks.Ports; namespace JSMR.Application.VoiceWorks.Ports;
public interface IVoiceWorkWriter public interface IVoiceWorkWriter
{ {
Task<SetVoiceWorkFavoriteResponse> SetFavoriteAsync(SetVoiceWorkFavoriteRequest request, CancellationToken cancellationToken); Task<SetVoiceWorkFavoriteResponse> SetFavoriteAsync(SetVoiceWorkFavoriteRequest request, CancellationToken cancellationToken);
Task<DeleteVoiceWorkResponse> DeleteAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken);
} }

View File

@@ -4,32 +4,34 @@ namespace JSMR.Application.VoiceWorks.Queries.Search;
public record VoiceWorkSearchResult public record VoiceWorkSearchResult
{ {
public int VoiceWorkId { get; init; } public int VoiceWorkId { get; set; }
public required string ProductId { get; init; } public required string ProductId { get; set; }
public string? OriginalProductId { get; init; } public string? OriginalProductId { get; set; }
public string? Description { get; init; } public string? Description { get; set; }
public required string ProductName { get; init; } public required string ProductName { get; set; }
public required string ProductUrl { get; init; } public required string ProductUrl { get; set; }
public bool HasImage { get; init; } public bool HasImage { get; set; }
public required string Maker { get; init; } public required string Maker { get; set; }
public required string MakerId { get; init; } public required string MakerId { get; set; }
public DateTime? ExpectedDate { get; init; } public DateTime? ExpectedDate { get; set; }
public DateTime? SalesDate { get; init; } public DateTime? SalesDate { get; set; }
public DateTime? PlannedReleaseDate { get; init; } public DateTime? PlannedReleaseDate { get; set; }
public int? Downloads { get; init; } public int? Downloads { get; set; }
public int? WishlistCount { get; init; } public int? WishlistCount { get; set; }
public byte? StarRating { get; init; } public byte? StarRating { get; set; }
public int? Votes { get; init; } public int? Votes { get; set; }
public bool HasTrial { get; init; } public bool HasTrial { get; set; }
public bool HasChobit { get; init; } public bool HasChobit { get; set; }
public AgeRating Rating { get; init; } public AgeRating Rating { get; set; }
public bool Favorite { get; init; } public bool Favorite { get; set; }
public byte Status { get; init; } public byte Status { get; set; }
public byte SubtitleLanguage { get; init; } public byte SubtitleLanguage { get; set; }
public bool? IsValid { get; init; } public bool? IsValid { get; set; }
public required VoiceWorkCircleItem Circle { get; set; } public required VoiceWorkCircleItem Circle { get; set; }
public VoiceWorkCircleItem? OriginalCircle { get; set; }
public VoiceWorkTagItem[] Tags { get; set; } = []; public VoiceWorkTagItem[] Tags { get; set; } = [];
public VoiceWorkCreatorItem[] Creators { get; set; } = []; public VoiceWorkCreatorItem[] Creators { get; set; } = [];
public string[] SupportedLanguages { get; set; } = [];
} }
public class VoiceWorkCircleItem public class VoiceWorkCircleItem

View File

@@ -0,0 +1,33 @@
using JSMR.Domain.Enums;
namespace JSMR.Domain.Entities;
public sealed class Job
{
public int Id { get; set; }
public string Code { get; set; } = null!;
public JobStatus Status { get; set; }
public string? RequestedByUserId { get; set; }
public string RequestedSource { get; set; } = "Manual";
public string? ParametersJson { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public DateTime? HeartbeatUtc { get; set; }
public string? WorkerName { get; set; }
public string? CurrentStep { get; set; }
public int? ProgressCurrent { get; set; }
public int? ProgressTotal { get; set; }
public string? ResultSummary { get; set; }
public string? Error { get; set; }
public bool CancellationRequested { get; set; }
public int AttemptCount { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace JSMR.Domain.Enums;
public enum JobStatus
{
Queued = 0,
Running = 1,
Succeeded = 2,
Failed = 3,
Cancelled = 4
}

View File

@@ -5,7 +5,9 @@ using JSMR.Application.Common.Caching;
using JSMR.Application.Creators.Ports; using JSMR.Application.Creators.Ports;
using JSMR.Application.Creators.Queries.Search.Ports; using JSMR.Application.Creators.Queries.Search.Ports;
using JSMR.Application.Enums; using JSMR.Application.Enums;
using JSMR.Application.Integrations.Ports; using JSMR.Application.Integrations.Chobit.Ports;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Jobs;
using JSMR.Application.Scanning.Ports; using JSMR.Application.Scanning.Ports;
using JSMR.Application.Tags.Ports; using JSMR.Application.Tags.Ports;
using JSMR.Application.Tags.Queries.Search.Ports; using JSMR.Application.Tags.Queries.Search.Ports;
@@ -19,11 +21,13 @@ using JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Infrastructure.Common.Time; using JSMR.Infrastructure.Common.Time;
using JSMR.Infrastructure.Data.Repositories.Circles; using JSMR.Infrastructure.Data.Repositories.Circles;
using JSMR.Infrastructure.Data.Repositories.Creators; using JSMR.Infrastructure.Data.Repositories.Creators;
using JSMR.Infrastructure.Data.Repositories.Jobs;
using JSMR.Infrastructure.Data.Repositories.Tags; using JSMR.Infrastructure.Data.Repositories.Tags;
using JSMR.Infrastructure.Data.Repositories.Users; using JSMR.Infrastructure.Data.Repositories.Users;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks; using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Ingestion; using JSMR.Infrastructure.Ingestion;
using JSMR.Infrastructure.Integrations.Chobit;
using JSMR.Infrastructure.Integrations.DLSite; using JSMR.Infrastructure.Integrations.DLSite;
using JSMR.Infrastructure.Scanning; using JSMR.Infrastructure.Scanning;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -49,6 +53,9 @@ public static class InfrastructureServiceCollectionExtensions
services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English); services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English);
services.AddScoped<IVoiceWorkScannerRepository, VoiceWorkScannerRepository>(); services.AddScoped<IVoiceWorkScannerRepository, VoiceWorkScannerRepository>();
services.AddScoped<IReleasedWorksProvider, ReleasedWorksProvider>();
services.AddScoped<IVoiceWorkIngestBuilder, VoiceWorkIngestBuilder>();
services.AddKeyedScoped<IVoiceWorkUpdater, VoiceWorkUpdater>(Locale.Japanese); services.AddKeyedScoped<IVoiceWorkUpdater, VoiceWorkUpdater>(Locale.Japanese);
services.AddKeyedScoped<IVoiceWorkUpdater, EnglishVoiceWorkUpdater>(Locale.English); services.AddKeyedScoped<IVoiceWorkUpdater, EnglishVoiceWorkUpdater>(Locale.English);
services.AddScoped<IVoiceWorkUpdaterRepository, VoiceWorkUpdaterRepository>(); services.AddScoped<IVoiceWorkUpdaterRepository, VoiceWorkUpdaterRepository>();
@@ -67,6 +74,9 @@ public static class InfrastructureServiceCollectionExtensions
services.AddScoped<ICreatorSearchProvider, CreatorSearchProvider>(); services.AddScoped<ICreatorSearchProvider, CreatorSearchProvider>();
services.AddScoped<ICreatorWriter, CreatorWriter>(); services.AddScoped<ICreatorWriter, CreatorWriter>();
services.AddScoped<IJobRepository, JobRepository>();
services.AddScoped<IJobProgressWriter, JobProgressWriter>();
services.AddSingleton<ICache, MemoryCacheAdapter>(); services.AddSingleton<ICache, MemoryCacheAdapter>();
services.AddSingleton<ISpamCircleCache, SpamCircleCache>(); services.AddSingleton<ISpamCircleCache, SpamCircleCache>();
@@ -76,6 +86,9 @@ public static class InfrastructureServiceCollectionExtensions
services.AddSingleton<ITimeProvider, TokyoTimeProvider>(); services.AddSingleton<ITimeProvider, TokyoTimeProvider>();
services.AddHttpServices(); services.AddHttpServices();
services.AddNewHttpServices();
services.AddScoped<IUserRepository, UserRepository>();
return services; return services;
} }
@@ -113,7 +126,50 @@ public static class InfrastructureServiceCollectionExtensions
// Register DLSiteClient as a normal scoped service // Register DLSiteClient as a normal scoped service
services.AddScoped<IDLSiteClient, DLSiteClient>(); services.AddScoped<IDLSiteClient, DLSiteClient>();
services.AddScoped<IUserRepository, UserRepository>(); return services;
}
private static IServiceCollection AddNewHttpServices(this IServiceCollection services)
{
services.AddHttpClient<IDLSiteClient, DLSiteClient>((sp, http) =>
{
http.BaseAddress = new Uri("https://www.dlsite.com/");
http.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
http.Timeout = TimeSpan.FromSeconds(15);
})
.AddResilienceHandler("dlsite", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429)
});
});
services.AddHttpClient<IChobitClient, ChobitClient>((sp, http) =>
{
http.BaseAddress = new Uri("https://chobit.cc/");
http.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
http.Timeout = TimeSpan.FromSeconds(15);
})
.AddResilienceHandler("chobit", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429)
});
});
return services; return services;
} }

View File

@@ -18,6 +18,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<Series> Series { get; set; } public DbSet<Series> Series { get; set; }
public DbSet<VoiceWorkSearch> VoiceWorkSearches { get; set; } public DbSet<VoiceWorkSearch> VoiceWorkSearches { get; set; }
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<Job> Jobs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -0,0 +1,44 @@
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace JSMR.Infrastructure.Data.Configuration;
public sealed class JobConfiguration : IEntityTypeConfiguration<Job>
{
public void Configure(EntityTypeBuilder<Job> builder)
{
builder.ToTable("Jobs");
builder.HasKey(x => x.Id);
builder.Property(x => x.Code)
.HasMaxLength(100)
.IsRequired();
builder.Property(x => x.RequestedByUserId)
.HasMaxLength(100);
builder.Property(x => x.RequestedSource)
.HasMaxLength(50)
.IsRequired();
builder.Property(x => x.WorkerName)
.HasMaxLength(200);
builder.Property(x => x.CurrentStep)
.HasMaxLength(500);
builder.Property(x => x.ResultSummary)
.HasMaxLength(2000);
builder.Property(x => x.Error)
.HasColumnType("TEXT");
builder.Property(x => x.ParametersJson)
.HasColumnType("LONGTEXT");
builder.HasIndex(x => new { x.Status, x.CreatedUtc });
builder.HasIndex(x => x.Code);
}
}

View File

@@ -44,8 +44,8 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
Expression<Func<CreatorSearchItem, object?>> selector = field switch Expression<Func<CreatorSearchItem, object?>> selector = field switch
{ {
CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount, CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount,
CreatorSortField.Favorite => x => !x.Favorite, CreatorSortField.Favorite => x => x.Favorite,
CreatorSortField.Blacklisted => x => !x.Blacklisted, CreatorSortField.Blacklisted => x => x.Blacklisted,
_ => x => x.Name _ => x => x.Name
}; };

View File

@@ -0,0 +1,80 @@
using JSMR.Application.Jobs;
using JSMR.Domain.Entities;
using JSMR.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Jobs;
public sealed class JobProgressWriter(AppDbContext dbContext) : IJobProgressWriter
{
public async Task SetStepAsync(int jobId, string step, CancellationToken canellationToken)
{
Job? job = await dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == jobId, canellationToken);
if (job is null)
return;
job.CurrentStep = step;
job.HeartbeatUtc = DateTime.UtcNow;
await dbContext.SaveChangesAsync(canellationToken);
}
public async Task SetProgressAsync(int jobId, int? current, int? total, CancellationToken canellationToken)
{
Job? job = await dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == jobId, canellationToken);
if (job is null)
return;
job.ProgressCurrent = current;
job.ProgressTotal = total;
job.HeartbeatUtc = DateTime.UtcNow;
await dbContext.SaveChangesAsync(canellationToken);
}
public async Task SetHeartbeatAsync(int jobId, CancellationToken canellationToken)
{
Job? job = await dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == jobId, canellationToken);
if (job is null)
return;
job.HeartbeatUtc = DateTime.UtcNow;
await dbContext.SaveChangesAsync(canellationToken);
}
public async Task CompleteAsync(int jobId, string? summary, CancellationToken canellationToken)
{
Job? job = await dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == jobId, canellationToken);
if (job is null)
return;
job.Status = JobStatus.Succeeded;
job.CompletedUtc = DateTime.UtcNow;
job.HeartbeatUtc = DateTime.UtcNow;
job.ResultSummary = summary;
job.CurrentStep = "Completed";
await dbContext.SaveChangesAsync(canellationToken);
}
public async Task FailAsync(int jobId, string error, CancellationToken canellationToken)
{
Job? job = await dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == jobId, canellationToken);
if (job is null)
return;
job.Status = JobStatus.Failed;
job.CompletedUtc = DateTime.UtcNow;
job.HeartbeatUtc = DateTime.UtcNow;
job.Error = error;
job.CurrentStep = "Failed";
await dbContext.SaveChangesAsync(canellationToken);
}
}

View File

@@ -0,0 +1,52 @@
using JSMR.Application.Jobs;
using JSMR.Domain.Entities;
using JSMR.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Jobs;
public sealed class JobRepository(AppDbContext dbContext) : IJobRepository
{
public async Task<Job> AddAsync(Job job, CancellationToken cancellationToken)
{
await dbContext.Jobs.AddAsync(job, cancellationToken);
return job;
}
public Task<Job?> GetByIdAsync(int id, CancellationToken cancellationToken)
=> dbContext.Jobs.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public async Task<IReadOnlyList<Job>> GetRecentAsync(int take, CancellationToken cancellationToken)
=> await dbContext.Jobs
.OrderByDescending(x => x.CreatedUtc)
.Take(take)
.ToListAsync(cancellationToken);
public Task<bool> AnyRunningAsync(CancellationToken cancellationToken)
=> dbContext.Jobs.AnyAsync(x => x.Status == JobStatus.Running, cancellationToken);
public async Task<Job?> TryClaimNextQueuedAsync(string workerName, CancellationToken cancellationToken)
{
Job? next = await dbContext.Jobs
.Where(x => x.Status == JobStatus.Queued)
.OrderBy(x => x.CreatedUtc)
.FirstOrDefaultAsync(cancellationToken);
if (next is null)
return null;
next.Status = JobStatus.Running;
next.StartedUtc = DateTime.UtcNow;
next.HeartbeatUtc = DateTime.UtcNow;
next.WorkerName = workerName;
next.AttemptCount += 1;
await dbContext.SaveChangesAsync(cancellationToken);
return next;
}
public Task SaveChangesAsync(CancellationToken ct)
=> dbContext.SaveChangesAsync(ct);
}

View File

@@ -3,6 +3,7 @@ using JSMR.Application.Enums;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Domain.Entities; using JSMR.Domain.Entities;
using JSMR.Domain.Enums; using JSMR.Domain.Enums;
using JSMR.Domain.ValueObjects;
using JSMR.Infrastructure.Common.Queries; using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions; using System.Linq.Expressions;
@@ -60,9 +61,10 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
break; break;
} }
if (criteria.SupportedLanguages.Length > 0) //if (criteria.SupportedLanguages.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.AsEnumerable().Contains((Language)x.VoiceWork.SubtitleLanguage)); // filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.AsEnumerable().Contains((Language)x.VoiceWork.SubtitleLanguage));
filteredQuery = ApplySupportedLanguageFilter(filteredQuery, criteria);
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria); filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria); filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria); filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria);
@@ -118,6 +120,26 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
return query.Where(x => voiceWorkIds.Contains(x.VoiceWork.VoiceWorkId)); return query.Where(x => voiceWorkIds.Contains(x.VoiceWork.VoiceWorkId));
} }
private IQueryable<VoiceWorkQuery> ApplySupportedLanguageFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{
if (criteria.SupportedLanguages.Length == 0)
return query;
List<string> languageCodes = [];
foreach (Language language in criteria.SupportedLanguages)
{
if (SupportedLanguage.TryFromLanguage(language, out SupportedLanguage? supportedLanguage))
languageCodes.Add(supportedLanguage.Code);
}
if (languageCodes.Count == 0)
return query;
return query.Where(q => context.VoiceWorkSupportedLanguages.Any(sl => sl.VoiceWorkId == q.VoiceWork.VoiceWorkId && languageCodes.Contains(sl.Language)));
}
private IQueryable<VoiceWorkQuery> ApplyCircleStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria) private IQueryable<VoiceWorkQuery> ApplyCircleStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{ {
if (criteria.CircleStatus is null) if (criteria.CircleStatus is null)
@@ -432,6 +454,7 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
PlannedReleaseDate = voiceWork.PlannedReleaseDate, PlannedReleaseDate = voiceWork.PlannedReleaseDate,
Downloads = voiceWork.Downloads, Downloads = voiceWork.Downloads,
WishlistCount = voiceWork.WishlistCount, WishlistCount = voiceWork.WishlistCount,
Rating = (AgeRating)voiceWork.Rating,
Status = voiceWork.Status, Status = voiceWork.Status,
SubtitleLanguage = voiceWork.SubtitleLanguage, SubtitleLanguage = voiceWork.SubtitleLanguage,
HasTrial = voiceWork.HasTrial, HasTrial = voiceWork.HasTrial,
@@ -449,16 +472,24 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
int[] voiceWorkIds = [.. items.Select(i => i.VoiceWorkId)]; int[] voiceWorkIds = [.. items.Select(i => i.VoiceWorkId)];
Dictionary<int, VoiceWorkTagItem[]> tagsByVw = await GetTagsAsync(voiceWorkIds, cancellationToken); var tagsByVoiceWork = await GetTagsAsync(voiceWorkIds, cancellationToken);
Dictionary<int, VoiceWorkCreatorItem[]> creatorsByVw = await GetCreatorsAsync(voiceWorkIds, cancellationToken); var creatorsByVoiceWork = await GetCreatorsAsync(voiceWorkIds, cancellationToken);
var supportedLanguagesByVoiceWork = await GetSupportedLanguagesAsync(voiceWorkIds, cancellationToken);
var originalCirclesByVoiceWork = await GetOriginalCircles(voiceWorkIds, cancellationToken);
foreach (VoiceWorkSearchResult item in items) foreach (VoiceWorkSearchResult item in items)
{ {
if (tagsByVw.TryGetValue(item.VoiceWorkId, out VoiceWorkTagItem[]? tags)) if (tagsByVoiceWork.TryGetValue(item.VoiceWorkId, out VoiceWorkTagItem[]? tags))
item.Tags = tags; item.Tags = tags;
if (creatorsByVw.TryGetValue(item.VoiceWorkId, out VoiceWorkCreatorItem[]? creators)) if (creatorsByVoiceWork.TryGetValue(item.VoiceWorkId, out VoiceWorkCreatorItem[]? creators))
item.Creators = creators; item.Creators = creators;
if (supportedLanguagesByVoiceWork.TryGetValue(item.VoiceWorkId, out string[]? supportedLanguages))
item.SupportedLanguages = supportedLanguages;
if (originalCirclesByVoiceWork.TryGetValue(item.VoiceWorkId, out VoiceWorkCircleItem? voiceWorkCircleItem))
item.OriginalCircle = voiceWorkCircleItem;
} }
} }
@@ -499,4 +530,51 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
g => g.Select(r => new VoiceWorkCreatorItem { CreatorId = r.CreatorId, Name = r.Name, IsFavorite = r.Favorite, IsBlacklisted = r.Blacklisted }).ToArray() g => g.Select(r => new VoiceWorkCreatorItem { CreatorId = r.CreatorId, Name = r.Name, IsFavorite = r.Favorite, IsBlacklisted = r.Blacklisted }).ToArray()
); );
} }
private async Task<Dictionary<int, string[]>> GetSupportedLanguagesAsync(int[] voiceWorkIds, CancellationToken cancellationToken)
{
var supportedLanguageRows = await (
from voiceWorkSupportedLanguage in context.VoiceWorkSupportedLanguages.AsNoTracking()
where voiceWorkIds.Contains(voiceWorkSupportedLanguage.VoiceWorkId)
select new { voiceWorkSupportedLanguage.VoiceWorkId, voiceWorkSupportedLanguage.Language }
).ToListAsync(cancellationToken);
return supportedLanguageRows
.GroupBy(r => r.VoiceWorkId)
.ToDictionary(
g => g.Key,
g => g.Select(r => r.Language).ToArray()
);
}
private async Task<Dictionary<int, VoiceWorkCircleItem>> GetOriginalCircles(int[] voiceWorkIds, CancellationToken cancellationToken)
{
var originalCircleRows = await (
from voiceWork in context.VoiceWorks.AsNoTracking()
join orignalVoiceWork in context.VoiceWorks.AsNoTracking() on voiceWork.OriginalProductId equals orignalVoiceWork.ProductId
join originalCircle in context.Circles.AsNoTracking() on orignalVoiceWork.CircleId equals originalCircle.CircleId
where voiceWorkIds.Contains(voiceWork.VoiceWorkId)
select new
{
voiceWork.VoiceWorkId,
originalCircle.Name,
originalCircle.MakerId,
originalCircle.Favorite,
originalCircle.Blacklisted
}
).ToListAsync(cancellationToken);
return originalCircleRows
.GroupBy(r => r.VoiceWorkId)
.ToDictionary(
g => g.Key,
g => g.Select(r => new VoiceWorkCircleItem()
{
Name = r.Name,
MakerId = r.MakerId,
IsFavorite = r.Favorite,
IsBlacklisted = r.Blacklisted
}).First()
);
}
} }

View File

@@ -1,7 +1,7 @@
using JSMR.Application.VoiceWorks.Commands.SetFavorite; using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Ports; using JSMR.Application.VoiceWorks.Ports;
using JSMR.Domain.Entities; using JSMR.Domain.Entities;
using JSMR.Infrastructure.Common.Time;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
@@ -18,6 +18,57 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
return new SetVoiceWorkFavoriteResponse(request.VoiceWorkId, request.IsFavorite); return new SetVoiceWorkFavoriteResponse(request.VoiceWorkId, request.IsFavorite);
} }
public async Task<DeleteVoiceWorkResponse> DeleteAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken)
{
Dictionary<int, DeleteVoiceWorkStatus> results = request.VoiceWorkIds.Select(x => x)
.ToDictionary(x => x, x => DeleteVoiceWorkStatus.NotFound);
VoiceWork[] voiceWorks = await dbContext.VoiceWorks
.Where(voiceWork => request.VoiceWorkIds.Contains(voiceWork.VoiceWorkId))
.Include(x => x.Circle)
.ToArrayAsync(cancellationToken);
List<VoiceWork> voiceWorksToDelete = [];
foreach (VoiceWork voiceWork in voiceWorks)
{
if (results.ContainsKey(voiceWork.VoiceWorkId) == false)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotFound;
continue;
}
if (voiceWork.Circle is null)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotFound;
continue;
}
if (voiceWork.IsValid == true && voiceWork.Circle.Spam == false)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotAllowed;
continue;
}
voiceWorksToDelete.Add(voiceWork);
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.Deleted;
}
int[] voiceWorkIdsToDelete = [.. voiceWorksToDelete.Select(x => x.VoiceWorkId)];
if (voiceWorkIdsToDelete.Length > 0)
{
dbContext.VoiceWorks.RemoveRange(voiceWorksToDelete);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.VoiceWorkSearches
.Where(x => voiceWorkIdsToDelete.Contains(x.VoiceWorkId))
.ExecuteDeleteAsync(cancellationToken);
}
return new DeleteVoiceWorkResponse(results);
}
private async Task<VoiceWork> GetVoiceWorkAsync(int voiceWorkId, CancellationToken cancellationToken) private async Task<VoiceWork> GetVoiceWorkAsync(int voiceWorkId, CancellationToken cancellationToken)
{ {
return await dbContext.VoiceWorks.FirstOrDefaultAsync(voiceWork => voiceWork.VoiceWorkId == voiceWorkId, cancellationToken) return await dbContext.VoiceWorks.FirstOrDefaultAsync(voiceWork => voiceWork.VoiceWorkId == voiceWorkId, cancellationToken)

View File

@@ -0,0 +1,48 @@
using JSMR.Application.Enums;
namespace JSMR.Infrastructure.Globalization;
internal class LocaleMapper
{
// TODO: Deprecate
public static readonly IReadOnlyDictionary<Locale, (string Abbreviation, string Code)> Map =
new Dictionary<Locale, (string, string)>
{
{ Locale.Japanese, ("jp", "ja_JP") },
{ Locale.English, ("en", "en_US") },
{ Locale.ChineseSimplified, ("zh-cn", "zh_CN") },
{ Locale.ChineseTraditional, ("zh-tw", "zh_TW") },
{ Locale.Korean, ("ko", "ko_KR") },
};
public static string ToDLSiteLocale(Locale locale) => locale switch
{
Locale.Japanese => "ja-jp",
Locale.English => "en-us",
Locale.ChineseSimplified => "zh-cn",
Locale.ChineseTraditional => "zh-tw",
Locale.Korean => "ko-kr",
_ => throw new NotSupportedException($"Locale '{locale}' is not supported.")
};
public static string ToDLSiteApiLocale(Locale locale) => locale switch
{
Locale.Japanese => "ja_JP",
Locale.English => "en_US",
Locale.ChineseSimplified => "zh_CN",
Locale.ChineseTraditional => "zh_TW",
Locale.Korean => "ko_KR",
_ => throw new NotSupportedException($"Locale '{locale}' is not supported.")
};
public static string ToAbbreviation(Locale locale) => locale switch
{
Locale.Japanese => "jp",
Locale.English => "en",
Locale.ChineseSimplified => "zh-cn",
Locale.ChineseTraditional => "zh-tw",
Locale.Korean => "ko",
_ => throw new NotSupportedException($"Locale '{locale}' is not supported.")
};
}

View File

@@ -1,91 +1,134 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.IO;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
namespace JSMR.Infrastructure.Http; namespace JSMR.Infrastructure.Http;
public abstract class ApiClient(IHttpService http, ILogger logger, JsonSerializerOptions? json = null) public abstract class ApiClient(HttpClient http, ILogger logger, JsonSerializerOptions? json = null)
{ {
protected async Task<T> GetJsonAsync<T>(string url, CancellationToken cancellationToken = default) protected async Task<TResponse> GetJsonAsync<TResponse>(
string url,
Action<HttpRequestHeaders>? configureHeaders = null,
CancellationToken cancellationToken = default)
{ {
HttpStringResponse response = await http.GetAsync(url, cancellationToken); using HttpRequestMessage request = new(HttpMethod.Get, url);
configureHeaders?.Invoke(request.Headers);
if (response.Content is null) LogRequest(request);
throw new Exception("No content to deserialize");
return JsonSerializer.Deserialize<T>(response.Content, json) using HttpResponseMessage response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}."); await EnsureSuccess(response).ConfigureAwait(false);
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
try
{
return await JsonSerializer.DeserializeAsync<TResponse>(stream, json, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(TResponse).Name} from {url}.");
}
catch (JsonException ex)
{
logger.LogError(ex,
"Failed to deserialize JSON from {Url}. ContentLengthHeader={ContentLengthHeader}",
url,
response.Content.Headers.ContentLength);
throw;
} }
//protected async Task<T> GetJsonAsync<T>( }
// string url,
// Action<HttpRequestHeaders>? configureHeaders = null,
// CancellationToken ct = default
// )
//{
// using var req = new HttpRequestMessage(HttpMethod.Get, url);
// configureHeaders?.Invoke(req.Headers);
// LogRequest(req); protected async Task<TResponse> GetJsonpAsync<TResponse>(
string url,
Action<HttpRequestHeaders>? configureHeaders = null,
CancellationToken cancellationToken = default)
{
using HttpRequestMessage request = new(HttpMethod.Get, url);
configureHeaders?.Invoke(request.Headers);
// using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); LogRequest(request);
// await EnsureSuccess(res).ConfigureAwait(false);
// var stream = await res.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); using HttpResponseMessage response = await http.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
// var model = await JsonSerializer.DeserializeAsync<T>(stream, json, ct).ConfigureAwait(false) await EnsureSuccess(response).ConfigureAwait(false);
// ?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}.");
// return model; string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
//}
//protected async Task<TResponse> PostJsonAsync<TRequest, TResponse>( string jsonBody = ExtractJsonFromJsonp(body);
// string url,
// TRequest payload,
// Action<HttpRequestHeaders>? configureHeaders = null,
// CancellationToken ct = default)
//{
// var content = new StringContent(JsonSerializer.Serialize(payload, json), Encoding.UTF8, "application/json");
// using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
// configureHeaders?.Invoke(req.Headers);
// LogRequest(req); return JsonSerializer.Deserialize<TResponse>(jsonBody, json)
?? throw new InvalidOperationException($"Failed to deserialize JSONP payload to {typeof(TResponse).Name} from {url}.");
}
// using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); private static string ExtractJsonFromJsonp(string body)
// await EnsureSuccess(res).ConfigureAwait(false); {
if (string.IsNullOrWhiteSpace(body))
throw new InvalidOperationException("Response body was empty.");
// var stream = await res.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); body = body.Trim();
// var model = await JsonSerializer.DeserializeAsync<TResponse>(stream, json, ct).ConfigureAwait(false) int firstParen = body.IndexOf('(');
// ?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(TResponse).Name} from {url}."); int lastParen = body.LastIndexOf(')');
// return model; if (firstParen < 0 || lastParen <= firstParen)
//} throw new InvalidOperationException("Response was not valid JSONP.");
//protected virtual void LogRequest(HttpRequestMessage req) return body[(firstParen + 1)..lastParen].Trim();
// => logger.LogDebug("HTTP {Method} {Uri}", req.Method, req.RequestUri); }
//protected virtual void LogFailure(HttpResponseMessage res, string body) protected async Task<TResponse> PostJsonAsync<TRequest, TResponse>(
// => logger.LogWarning("HTTP {Status} for {Uri}. Body: {Body}", (int)res.StatusCode, res.RequestMessage?.RequestUri, Truncate(body, 500)); string url,
TRequest payload,
Action<HttpRequestHeaders>? configureHeaders = null,
CancellationToken cancellationToken = default)
{
StringContent content = new(JsonSerializer.Serialize(payload, json), Encoding.UTF8, "application/json");
//protected static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…"; using HttpRequestMessage request = new(HttpMethod.Post, url)
{
Content = content
};
//protected async Task EnsureSuccess(HttpResponseMessage res) configureHeaders?.Invoke(request.Headers);
//{
// if (res.IsSuccessStatusCode) return;
// string body; LogRequest(request);
// try { body = await res.Content.ReadAsStringAsync().ConfigureAwait(false); }
// catch { body = "<unable to read body>"; }
// LogFailure(res, body); using HttpResponseMessage response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(response).ConfigureAwait(false);
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<TResponse>(stream, json, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(TResponse).Name} from {url}.");
}
protected virtual void LogRequest(HttpRequestMessage request)
=> logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
protected virtual void LogFailure(HttpResponseMessage response, string body)
=> logger.LogWarning("HTTP {Status} for {Uri}. Body: {Body}", (int)response.StatusCode, response.RequestMessage?.RequestUri, Truncate(body, 500));
protected static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…";
protected async Task EnsureSuccess(HttpResponseMessage res)
{
if (res.IsSuccessStatusCode)
return;
string body;
try { body = await res.Content.ReadAsStringAsync().ConfigureAwait(false); }
catch { body = "<unable to read body>"; }
LogFailure(res, body);
//Throw a richer exception(you can customize per API) //Throw a richer exception(you can customize per API)
// throw new HttpRequestException( throw new HttpRequestException(
// $"Request to {res.RequestMessage?.RequestUri} failed: {(int)res.StatusCode} {res.ReasonPhrase}. Body: {Truncate(body, 1000)}", $"Request to {res.RequestMessage?.RequestUri} failed: {(int)res.StatusCode} {res.ReasonPhrase}. Body: {Truncate(body, 1000)}",
// null, null,
// res.StatusCode); res.StatusCode);
//} }
} }

View File

@@ -21,11 +21,21 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
.Include(vw => vw.EnglishVoiceWorks) .Include(vw => vw.EnglishVoiceWorks)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
Dictionary<int, VoiceWorkSearch> existingSearches = await dbContext.VoiceWorkSearches
.Where(s => voiceWorkIds.Contains(s.VoiceWorkId))
.ToDictionaryAsync(s => s.VoiceWorkId, cancellationToken);
int[] tagIds = [.. batch.SelectMany(vw => vw.Tags).Select(vwt => vwt.TagId).Distinct()];
Dictionary<int, EnglishTag> englishTags = await dbContext.EnglishTags
.Where(et => tagIds.Contains(et.TagId))
.ToDictionaryAsync(et => et.TagId, cancellationToken);
foreach (var voiceWork in batch) foreach (var voiceWork in batch)
{ {
try try
{ {
UpdateSearchText(voiceWork); UpdateSearchText(voiceWork, existingSearches, englishTags);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -33,17 +43,14 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
} }
} }
dbContext.SaveChanges(); await dbContext.SaveChangesAsync(cancellationToken);
} }
private void UpdateSearchText(VoiceWork voiceWork) private void UpdateSearchText(VoiceWork voiceWork, Dictionary<int, VoiceWorkSearch> existingSearches, Dictionary<int, EnglishTag> englishTags)
{ {
string searchText = GetSearchText(voiceWork); string searchText = GetSearchText(voiceWork, englishTags);
var searchEntry = dbContext.VoiceWorkSearches if (!existingSearches.TryGetValue(voiceWork.VoiceWorkId, out var searchEntry))
.FirstOrDefault(s => s.VoiceWorkId == voiceWork.VoiceWorkId);
if (searchEntry == null)
{ {
dbContext.VoiceWorkSearches.Add(new VoiceWorkSearch dbContext.VoiceWorkSearches.Add(new VoiceWorkSearch
{ {
@@ -52,12 +59,15 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
}); });
} }
else else
{
if (!string.Equals(searchEntry.SearchText, searchText, StringComparison.Ordinal))
{ {
searchEntry.SearchText = searchText; searchEntry.SearchText = searchText;
} }
} }
}
private string GetSearchText(VoiceWork voiceWork) private string GetSearchText(VoiceWork voiceWork, Dictionary<int, EnglishTag> englishTags)
{ {
var english = voiceWork.EnglishVoiceWorks.FirstOrDefault(); var english = voiceWork.EnglishVoiceWorks.FirstOrDefault();
@@ -80,12 +90,8 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
AppendRaw(sb, tag.Name); AppendRaw(sb, tag.Name);
var englishTag = dbContext.EnglishTags.FirstOrDefault(et => et.TagId == tag.TagId); if (englishTags.TryGetValue(tag.TagId, out var englishTag))
AppendRaw(sb, englishTag.Name);
if (englishTag is null)
continue;
AppendRaw(sb, englishTag?.Name);
} }
foreach (var creator in voiceWork.Creators.Select(vwc => vwc.Creator)) foreach (var creator in voiceWork.Creators.Select(vwc => vwc.Creator))

View File

@@ -72,6 +72,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
.AsSplitQuery() .AsSplitQuery()
.Include(v => v.Creators) .Include(v => v.Creators)
.Include(v => v.Tags) .Include(v => v.Tags)
.Include(v => v.EnglishVoiceWorks)
.Include(v => v.Localizations) .Include(v => v.Localizations)
.Include(v => v.SupportedLanguages) .Include(v => v.SupportedLanguages)
.ToDictionaryAsync(v => v.ProductId, cancellationToken), .ToDictionaryAsync(v => v.ProductId, cancellationToken),
@@ -161,6 +162,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
UpsertVoiceWorkCreators(ingest, upsertContext); UpsertVoiceWorkCreators(ingest, upsertContext);
UpsertVoiceWorkSupportedLanguages(ingest, upsertContext); UpsertVoiceWorkSupportedLanguages(ingest, upsertContext);
UpsertSeries(ingest, upsertContext); UpsertSeries(ingest, upsertContext);
UpsertVoiceWorkLocalizations(ingest, upsertContext);
return dbContext.Entry(voiceWork).State switch return dbContext.Entry(voiceWork).State switch
{ {
@@ -206,7 +208,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
voiceWork.Downloads = ingest.Downloads; voiceWork.Downloads = ingest.Downloads;
voiceWork.WishlistCount = ingest.WishlistCount; voiceWork.WishlistCount = ingest.WishlistCount;
voiceWork.HasTrial = ingest.HasTrial; voiceWork.HasTrial = ingest.HasTrial;
voiceWork.HasChobit = ingest.HasDLPlay; voiceWork.HasChobit = ingest.HasChobit;
voiceWork.StarRating = ingest.StarRating; voiceWork.StarRating = ingest.StarRating;
voiceWork.Votes = ingest.Votes; voiceWork.Votes = ingest.Votes;
voiceWork.OriginalProductId = ingest.Translation?.OriginalProductId; voiceWork.OriginalProductId = ingest.Translation?.OriginalProductId;
@@ -372,7 +374,9 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
int position = 1; int position = 1;
foreach (string tagName in ingest.Tags) string[] distinctTagNames = [.. ingest.Tags.Distinct()];
foreach (string tagName in distinctTagNames)
{ {
Tag tag = upsertContext.Tags[tagName]; Tag tag = upsertContext.Tags[tagName];
@@ -399,7 +403,9 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
int position = 1; int position = 1;
foreach (string creatorName in ingest.Creators) string[] distinctCreatorNames = [.. ingest.Creators.Distinct()];
foreach (string creatorName in distinctCreatorNames)
{ {
Creator creator = upsertContext.Creators[creatorName]; Creator creator = upsertContext.Creators[creatorName];
@@ -424,6 +430,9 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId]; VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
Dictionary<string, VoiceWorkSupportedLanguage> existingLanguageLinks = voiceWork.SupportedLanguages.ToDictionary(x => x.Language); Dictionary<string, VoiceWorkSupportedLanguage> existingLanguageLinks = voiceWork.SupportedLanguages.ToDictionary(x => x.Language);
if (ingest.SupportedLanguages.Count == 0)
return;
foreach (SupportedLanguage supportedLanguage in ingest.SupportedLanguages) foreach (SupportedLanguage supportedLanguage in ingest.SupportedLanguages)
{ {
if (!existingLanguageLinks.TryGetValue(supportedLanguage.Code, out VoiceWorkSupportedLanguage? voiceWorkSupportedLanguage)) if (!existingLanguageLinks.TryGetValue(supportedLanguage.Code, out VoiceWorkSupportedLanguage? voiceWorkSupportedLanguage))
@@ -437,6 +446,14 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
dbContext.VoiceWorkSupportedLanguages.Add(voiceWorkSupportedLanguage); dbContext.VoiceWorkSupportedLanguages.Add(voiceWorkSupportedLanguage);
} }
} }
foreach (string existingLinkCode in existingLanguageLinks.Keys)
{
if (!ingest.SupportedLanguages.Any(x => x.Code == existingLinkCode))
{
dbContext.VoiceWorkSupportedLanguages.Remove(existingLanguageLinks[existingLinkCode]);
}
}
} }
private void UpsertSeries(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) private void UpsertSeries(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
@@ -473,4 +490,39 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
return series; return series;
} }
private void UpsertVoiceWorkLocalizations(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
// For now, just adding/updating English voice works
foreach (VoiceWorkLocalizationIngest localizationIngest in ingest.Localizations)
{
if (localizationIngest.Language is Language.English)
{
EnglishVoiceWork englishVoiceWork = GetOrAddEnglishVoiceWork(ingest, upsertContext);
englishVoiceWork.ProductName = localizationIngest.Title ?? string.Empty;
englishVoiceWork.Description = localizationIngest.Description ?? string.Empty;
englishVoiceWork.IsValid = true;
}
}
}
private EnglishVoiceWork GetOrAddEnglishVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
EnglishVoiceWork? englishVoiceWork = voiceWork.EnglishVoiceWorks.FirstOrDefault();
if (englishVoiceWork is null)
{
englishVoiceWork = new EnglishVoiceWork
{
VoiceWork = voiceWork,
ProductName = string.Empty,
Description = string.Empty
};
dbContext.EnglishVoiceWorks.Add(englishVoiceWork);
}
return englishVoiceWork;
}
} }

View File

@@ -1,15 +1,21 @@
using JSMR.Application.Integrations.Chobit.Models; using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Application.Integrations.Ports; using JSMR.Application.Integrations.Chobit.Ports;
using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace JSMR.Infrastructure.Integrations.Chobit; namespace JSMR.Infrastructure.Integrations.Chobit;
public class ChobitClient(IHttpService http, ILogger logger) : ApiClient(http, logger), IChobitClient public class ChobitClient(HttpClient http, ILogger<ChobitClient> logger) : ApiClient(http, logger), IChobitClient
{ {
public Task<ChobitWorkResult> GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default) public Task<ChobitResult> GetSampleInfoAsync(string productId, CancellationToken cancellationToken = default)
{ {
var url = $"api/v2/dlsite/embed?workno_list=${string.Join(",", productIds)}"; var url = $"api/v1/dlsite/embed?workno={productId}";
return GetJsonAsync<ChobitWorkResult>(url, cancellationToken); return GetJsonpAsync<ChobitResult>(url, cancellationToken: cancellationToken);
}
public Task<ChobitResultCollection> GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default)
{
var url = $"api/v2/dlsite/embed?workno_list={string.Join(",", productIds)}";
return GetJsonpAsync<ChobitResultCollection>(url, cancellationToken: cancellationToken);
} }
} }

View File

@@ -1,28 +1,48 @@
using JSMR.Application.Integrations.DLSite.Models; using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.Ports; using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Infrastructure.Globalization;
using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Integrations.DLSite.Mapping; using JSMR.Infrastructure.Integrations.DLSite.Mapping;
using JSMR.Infrastructure.Integrations.DLSite.Models; using JSMR.Infrastructure.Integrations.DLSite.Models;
using JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace JSMR.Infrastructure.Integrations.DLSite; namespace JSMR.Infrastructure.Integrations.DLSite;
public class DLSiteClient(IHttpService http, ILogger<DLSiteClient> logger) : ApiClient(http, logger), IDLSiteClient public class DLSiteClient(HttpClient http, ILogger<DLSiteClient> logger) : ApiClient(http, logger), IDLSiteClient
{ {
public async Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default) public async Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default)
{ {
if (productIds.Length == 0) string[] validProductIds = [.. productIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()];
return [];
if (validProductIds.Length == 0)
string productIdCollection = string.Join(",", productIds.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(productIdCollection))
return []; return [];
string productIdCollection = string.Join(",", validProductIds);
string url = $"maniax/product/info/ajax?product_id={productIdCollection}&cdn_cache_min=1"; string url = $"maniax/product/info/ajax?product_id={productIdCollection}&cdn_cache_min=1";
var productInfoCollection = await GetJsonAsync<ProductInfoCollection>(url, cancellationToken); ProductInfoCollection productInfoCollection = await GetJsonAsync<ProductInfoCollection>(url, cancellationToken: cancellationToken);
return DLSiteToDomainMapper.Map(productInfoCollection); return DLSiteToDomainMapper.Map(productInfoCollection);
} }
public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(ReleasedWorksRequest request, CancellationToken cancellationToken = default)
{
string locale = LocaleMapper.ToDLSiteLocale(request.Locale);
string date = request.Date.ToString("yyyy-MM-dd");
string url = $"maniax/new/work/api?locale={locale}&date={date}&period={request.Period}";
NewWorksApiResponse response = await GetJsonAsync<NewWorksApiResponse>(url, cancellationToken: cancellationToken);
if (response.Meta.Code != 200)
{
throw new InvalidOperationException(
$"DLsite returned code {response.Meta.Code}. " +
$"ErrorType: {response.Meta.ErrorType}. " +
$"ErrorMessage: {response.Meta.ErrorMessage}");
}
return DLSiteReleasedWorksMapper.Map(response);
}
} }

View File

@@ -1,8 +1,8 @@
using JSMR.Application.Integrations.Ports; //using JSMR.Application.Integrations.Ports;
using JSMR.Infrastructure.Integrations.DLSite.Serialization; //using JSMR.Infrastructure.Integrations.DLSite.Serialization;
using Microsoft.Extensions.DependencyInjection; //using Microsoft.Extensions.DependencyInjection;
namespace JSMR.Infrastructure.Integrations.DLSite; //namespace JSMR.Infrastructure.Integrations.DLSite;
public static class DLSiteClientRegistration public static class DLSiteClientRegistration
{ {

View File

@@ -0,0 +1,34 @@
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
namespace JSMR.Infrastructure.Integrations.DLSite.Mapping;
public static class DLSiteReleasedWorksMapper
{
public static ReleasedWorksCollection Map(NewWorksApiResponse response)
{
ReleasedWorksCollection result = [];
if (response.Data.Products.Length == 0)
return result;
foreach (NewWorksApiProduct product in response.Data.Products)
{
result.Add(product.Id, Map(product));
}
return result;
}
private static ReleasedWork Map(NewWorksApiProduct product)
{
return new ReleasedWork
{
ProductId = product.Id,
Title = product.Name ?? string.Empty,
MaskedTitle = product.NameMasked ?? string.Empty,
Description = product.Description ?? string.Empty,
MaskedDescription = product.DescriptionMasked ?? string.Empty
};
}
}

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
public record NewWorksApiData
{
[JsonPropertyName("products")]
public required NewWorksApiProduct[] Products { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
public record NewWorksApiMeta
{
[JsonPropertyName("code")]
public int Code { get; init; }
[JsonPropertyName("errorMessage")]
public string ErrorMessage { get; init; } = string.Empty;
[JsonPropertyName("errorType")]
public string ErrorType { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
public record NewWorksApiProduct
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("nameMasked")]
public string? NameMasked { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("descriptionMasked")]
public string? DescriptionMasked { get; init; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
public record NewWorksApiResponse
{
[JsonPropertyName("meta")]
public required NewWorksApiMeta Meta { get; init; }
[JsonPropertyName("data")]
public required NewWorksApiData Data { get; init; }
}

View File

@@ -15,16 +15,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" /> <PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.4.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NTextCat" Version="0.3.65" /> <PackageReference Include="NTextCat" Version="0.3.65" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
@@ -35,4 +35,8 @@
<ProjectReference Include="..\JSMR.Domain\JSMR.Domain.csproj" /> <ProjectReference Include="..\JSMR.Domain\JSMR.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Jobs\" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,6 @@
using JSMR.Application.Enums; using JSMR.Application.Enums;
using JSMR.Domain.ValueObjects; using JSMR.Domain.ValueObjects;
using JSMR.Infrastructure.Globalization;
namespace JSMR.Infrastructure.Scanning; namespace JSMR.Infrastructure.Scanning;
@@ -90,7 +91,10 @@ public sealed class DLSiteSearchFilterBuilder
public string BuildSearchQuery(int pageNumber, int pageSize) public string BuildSearchQuery(int pageNumber, int pageSize)
{ {
var (localeAbbreviation, localeCode) = LocaleMap.Map[_locale]; //string localeAbbreviation = LocaleMapper.ToAbbreviation(_locale);
//string localeCode = LocaleMapper.ToDLSiteLocale(_locale);
var (localeAbbreviation, localeCode) = LocaleMapper.Map[_locale];
using (var writer = new StringWriter()) using (var writer = new StringWriter())
{ {

View File

@@ -1,16 +0,0 @@
using JSMR.Application.Enums;
namespace JSMR.Infrastructure.Scanning;
internal class LocaleMap
{
public static readonly IReadOnlyDictionary<Locale, (string Abbreviation, string Code)> Map =
new Dictionary<Locale, (string, string)>
{
{ Locale.Japanese, ("jp", "ja_JP") },
{ Locale.English, ("en", "en_US") },
{ Locale.ChineseSimplified, ("zh-cn", "zh_CN") },
{ Locale.ChineseTraditional, ("zh-tw", "zh_TW") },
{ Locale.Korean, ("ko", "ko_KR") },
};
}

View File

@@ -0,0 +1,93 @@
using JSMR.Application.Enums;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports;
namespace JSMR.Infrastructure.Scanning;
public class ReleasedWorksProvider(IDLSiteClient dlsiteClient) : IReleasedWorksProvider
{
private const int MaxPeriodDays = 60;
public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
{
DateOnly[] salesDates =
[
.. scanResult.Works
.Where(x => x.SalesDate.HasValue)
.Select(x => x.SalesDate!.Value)
];
if (salesDates.Length == 0)
return [];
HashSet<string> productIds = [.. scanResult.Works.Select(x => x.ProductId)];
DateOnly minDate = salesDates.Min();
DateOnly maxDate = salesDates.Max();
ReleasedWorksCollection collection = [];
DateOnly chunkStart = minDate;
while (chunkStart <= maxDate)
{
int endDayNumber = Math.Min(chunkStart.DayNumber + MaxPeriodDays - 1, maxDate.DayNumber);
DateOnly chunkEnd = DateOnly.FromDayNumber(endDayNumber);
int period = chunkEnd.DayNumber - chunkStart.DayNumber + 1;
ReleasedWorksRequest request = new(
Locale: Locale.English,
Date: chunkEnd,
Period: period);
ReleasedWorksCollection chunk = await dlsiteClient.GetReleasedWorksAsync(request, cancellationToken);
foreach (string productId in chunk.Keys)
{
if (productIds.Contains(productId) == false)
continue;
if (collection.ContainsKey(productId))
continue;
collection.Add(productId, chunk[productId]);
}
chunkStart = chunkEnd.AddDays(1);
}
return collection;
}
//public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
//{
// DateOnly[] salesDates =
// [
// .. scanResult.Works
// .Where(x => x.SalesDate.HasValue)
// .Select(x => x.SalesDate!.Value)
// ];
// if (salesDates.Length == 0)
// return [];
// DateOnly minDate = salesDates.Min();
// DateOnly maxDate = salesDates.Max();
// DateOnly requestDate = minDate.AddDays(-1);
// DateOnly requestEndDate = maxDate.AddDays(1);
// int period = (requestEndDate.DayNumber - requestDate.DayNumber) + 1;
// ReleasedWorksRequest releasedWorksRequest = new(
// Locale: Locale.English,
// Date: requestEndDate,
// Period: period
// );
// return await dlsiteClient.GetReleasedWorksAsync(releasedWorksRequest, cancellationToken);
//}
}

View File

@@ -0,0 +1,94 @@
using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Application.Integrations.Chobit.Ports;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports;
using JSMR.Domain.Enums;
using JSMR.Infrastructure.Common.Languages;
namespace JSMR.Infrastructure.Scanning;
public class VoiceWorkIngestBuilder(
IDLSiteClient dlsiteClient,
IChobitClient chobitClient,
IReleasedWorksProvider releasedWorksProvider,
ILanguageIdentifier languageIdentifier) : IVoiceWorkIngestBuilder
{
public async Task<VoiceWorkIngest[]> BuildAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
{
string[] productIds = [.. scanResult.Works.Where(x => !string.IsNullOrWhiteSpace(x.ProductId)).Select(x => x.ProductId!)];
Task<VoiceWorkDetailCollection> detailsTask = dlsiteClient.GetVoiceWorkDetailsAsync(productIds, cancellationToken);
Task<ChobitResultCollection> chobitTask = chobitClient.GetSampleInfoAsync(productIds, cancellationToken);
Task<ReleasedWorksCollection> releasedTask = releasedWorksProvider.GetReleasedWorksAsync(scanResult, cancellationToken);
await Task.WhenAll(detailsTask, chobitTask, releasedTask);
VoiceWorkDetailCollection voiceWorkDetails = await detailsTask;
ChobitResultCollection chobitResults = await chobitTask;
ReleasedWorksCollection releasedWorkCollection = await releasedTask;
List<VoiceWorkIngest> ingests = [];
foreach (DLSiteWork work in scanResult.Works)
{
voiceWorkDetails.TryGetValue(work.ProductId!, out VoiceWorkDetails? details);
chobitResults.TryGetValue(work.ProductId, out ChobitResult? chobit);
releasedWorkCollection.TryGetValue(work.ProductId, out ReleasedWork? releasedWork);
VoiceWorkIngest ingest = new()
{
MakerId = work.MakerId,
MakerName = work.Maker,
ProductId = work.ProductId,
Title = details?.Title ?? work.ProductName,
Description = work.Description ?? string.Empty,
Tags = work.Tags,
Creators = work.Creators,
WishlistCount = details?.WishlistCount ?? 0,
Downloads = Math.Max(work.Downloads, details?.DownloadCount ?? 0),
HasTrial = work.HasTrial || (details?.HasTrial ?? false),
HasChobit = chobit?.Count > 0,
StarRating = work.StarRating,
Votes = work.Votes,
AgeRating = details?.AgeRating ?? work.AgeRating,
HasImage = !string.IsNullOrEmpty(work.ImageUrl) && !work.ImageUrl.Contains("no_img", StringComparison.OrdinalIgnoreCase),
SupportedLanguages = details?.SupportedLanguages ?? [],
ExpectedDate = work.ExpectedDate,
SalesDate = work.SalesDate,
RegistrationDate = details?.RegistrationDate,
AI = details?.AI ?? AIGeneration.None,
Series = details?.Series,
Translation = details?.Translation,
Localizations = GetLocalizationIngests(releasedWork)
};
ingests.Add(ingest);
}
return [.. ingests];
}
private VoiceWorkLocalizationIngest[] GetLocalizationIngests(ReleasedWork? releasedWork)
{
if (releasedWork is null)
return [];
Language titleLanguage = languageIdentifier.GetLanguage(releasedWork.Title);
Language descriptionLanguage = languageIdentifier.GetLanguage(releasedWork.Description);
if (titleLanguage is not Language.English && descriptionLanguage is not Language.English)
return [];
VoiceWorkLocalizationIngest localizationIngest = new()
{
Title = releasedWork.Title,
Description = releasedWork.Description,
Language = Language.English
};
return [localizationIngest];
}
}

View File

@@ -0,0 +1,73 @@
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Tests.Fixtures;
using Shouldly;
namespace JSMR.Tests.Data.Repositories.VoiceWorks;
public class Delete_Voice_Work_Tests(MariaDbContainerFixture container) : VoiceWorkRepositoryTests(container)
{
[Fact]
public async Task Try_Delete_Invalid_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 3;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.Deleted);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
}
[Fact]
public async Task Try_Delete_Valid_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 1;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.NotAllowed);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
}
[Fact]
public async Task Try_Delete_Missing_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 999;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.NotFound);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
}
}

View File

@@ -0,0 +1,45 @@
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Tests.Fixtures;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace JSMR.Tests.Data.Repositories.VoiceWorks;
public class Set_Is_Favorite_Tests(MariaDbContainerFixture container) : VoiceWorkRepositoryTests(container)
{
[Fact]
public async Task Set_Is_Favorite()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, true);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, true);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, false);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, false);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, true);
await SetFavoriteAndVerifyAsync(dbContext, writer, 1, false);
}
private static async Task SetFavoriteAndVerifyAsync(AppDbContext dbContext, VoiceWorkWriter writer, int voiceWorkId, bool isFavorite)
{
SetVoiceWorkFavoriteRequest request = new(
VoiceWorkId: voiceWorkId,
IsFavorite: isFavorite
);
SetVoiceWorkFavoriteResponse response = await writer.SetFavoriteAsync(request, TestContext.Current.CancellationToken);
response.VoiceWorkId.ShouldBe(voiceWorkId);
response.IsFavorite.ShouldBe(isFavorite);
VoiceWork? voiceWork = await dbContext.VoiceWorks
.SingleAsync(v => v.VoiceWorkId == voiceWorkId, TestContext.Current.CancellationToken);
voiceWork.ShouldNotBeNull();
voiceWork.Favorite.ShouldBe(isFavorite);
}
}

View File

@@ -0,0 +1,85 @@
using JSMR.Domain.Enums;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Data.Repositories.VoiceWorks;
public static class VoiceWorkRepositorySeedData
{
public 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" },
new() { CircleId = 4, Name = "Never Again", Spam = true, MakerId = "RG00004" }
);
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), Downloads = 500, WishlistCount = 750, StarRating = 35, IsValid = true },
new() { VoiceWorkId = 2, CircleId = 2, ProductId = "RJ0000002", ProductName = "Super Comfy ASMR", Description = "An amazing product!", Status = (byte)VoiceWorkStatus.NewRelease, SalesDate = new(2025, 1, 3), Downloads = 5000, WishlistCount = 12000, StarRating = 50, Favorite = true, IsValid = true },
new() { VoiceWorkId = 3, CircleId = 3, ProductId = "RJ0000003", ProductName = "Low Effort", Description = "A bad product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 2), Downloads = 50, WishlistCount = 100, StarRating = 20, IsValid = false },
new() { VoiceWorkId = 4, CircleId = 1, ProductId = "RJ0000004", ProductName = "Tomorrow Sounds", Description = "A average upcoming product.", Status = (byte)VoiceWorkStatus.Upcoming, ExpectedDate = new(2025, 1, 1), WishlistCount = 300, IsValid = true },
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, 11), WishlistCount = 10000, IsValid = true }
);
context.Creators.AddRange(
new() { CreatorId = 1, Name = "Average Creator" },
new() { CreatorId = 2, Name = "Good Creator" },
new() { CreatorId = 3, Name = "Bad Creator" }
);
context.VoiceWorkCreators.AddRange(
new() { VoiceWorkId = 1, CreatorId = 1 },
new() { VoiceWorkId = 2, CreatorId = 2 },
new() { VoiceWorkId = 3, CreatorId = 3 }
);
context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "Tsundere" },
new() { TagId = 3, Name = "Office Lady" }
);
context.VoiceWorkTags.AddRange(
new() { VoiceWorkId = 1, TagId = 1 },
new() { VoiceWorkId = 2, TagId = 2 },
new() { VoiceWorkId = 3, TagId = 3 }
);
context.VoiceWorkSupportedLanguages.AddRange(
new() { VoiceWorkSupportedLanguageId = 1, VoiceWorkId = 1, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 2, VoiceWorkId = 2, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 3, VoiceWorkId = 3, Language = "JPN" }
);
context.EnglishVoiceWorks.AddRange(
new() { EnglishVoiceWorkId = 1, VoiceWorkId = 1, ProductName = "Today Sounds", Description = "An average product." },
new() { EnglishVoiceWorkId = 2, VoiceWorkId = 2, ProductName = "Super Comfy ASMR", Description = "An amazing product!" },
new() { EnglishVoiceWorkId = 3, VoiceWorkId = 3, ProductName = "Low Effort", Description = "A bad product." },
new() { EnglishVoiceWorkId = 4, VoiceWorkId = 4, ProductName = "Tomorrow Sounds", Description = "A average upcoming product." },
new() { EnglishVoiceWorkId = 5, VoiceWorkId = 5, ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!" }
);
context.VoiceWorkLocalizations.AddRange(
new() { VoiceWorkLocalizationId = 1, VoiceWorkId = 1, Language = "JPN", ProductName = "Today Sounds", Description = "An average product." },
new() { VoiceWorkLocalizationId = 2, VoiceWorkId = 2, Language = "JPN", ProductName = "Super Comfy ASMR", Description = "An amazing product!" },
new() { VoiceWorkLocalizationId = 3, VoiceWorkId = 3, Language = "JPN", ProductName = "Low Effort", Description = "A bad product." },
new() { VoiceWorkLocalizationId = 4, VoiceWorkId = 4, Language = "JPN", ProductName = "Tomorrow Sounds", Description = "A average upcoming product." },
new() { VoiceWorkLocalizationId = 5, VoiceWorkId = 5, Language = "JPN", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!" }
);
context.VoiceWorkSearches.AddRange(
new() { VoiceWorkId = 1, SearchText = "Search 1" },
new() { VoiceWorkId = 2, SearchText = "Search 2" },
new() { VoiceWorkId = 3, SearchText = "Search 3" },
new() { VoiceWorkId = 4, SearchText = "Search 4" },
new() { VoiceWorkId = 5, SearchText = "Search 5" }
);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,15 @@
using JSMR.Infrastructure.Data;
using JSMR.Tests.Fixtures;
namespace JSMR.Tests.Data.Repositories.VoiceWorks;
public abstract class VoiceWorkRepositoryTests(MariaDbContainerFixture container)
{
protected async Task<AppDbContext> GetAppDbContextAsync()
{
return await MariaDbClone.CloneFromTemplateAsync(
container.RootConnectionString,
container.TemplateDbName,
seed: VoiceWorkRepositorySeedData.SeedAsync);
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.WebUtilities;
using Shouldly;
namespace JSMR.Tests.Http;
internal sealed class FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(handler(request));
}
}
public static class HttpRequestMessageAssertions
{
public static void ShouldHavePath(this HttpRequestMessage request, string expectedPath)
{
request.RequestUri.ShouldNotBeNull();
request.RequestUri!.AbsolutePath.ShouldBe(expectedPath);
}
public static void ShouldHaveQueryParam(this HttpRequestMessage request, string key, string expectedValue)
{
request.RequestUri.ShouldNotBeNull();
var query = QueryHelpers.ParseQuery(request.RequestUri!.Query);
query[key].ToString().ShouldBe(expectedValue);
}
}
//internal sealed class FakeHttpMessageHandler : HttpMessageHandler
//{
// private readonly Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> _handler;
// public List<HttpRequestMessage> Requests { get; } = [];
// public FakeHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
// {
// _handler = handler;
// }
// public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
// : this((request, _) => handler(request))
// {
// }
// protected override Task<HttpResponseMessage> SendAsync(
// HttpRequestMessage request,
// CancellationToken cancellationToken)
// {
// Requests.Add(request);
// HttpResponseMessage response = _handler(request, cancellationToken);
// return Task.FromResult(response);
// }
//}

View File

@@ -21,7 +21,7 @@ internal class IngestTestFactory
WishlistCount = 100, WishlistCount = 100,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,
HasImage = false, HasImage = false,
SupportedLanguages = [SupportedLanguage.Japanese], SupportedLanguages = [SupportedLanguage.Japanese],

View File

@@ -0,0 +1,154 @@
using JSMR.Application.Scanning.Contracts;
using JSMR.Domain.Entities;
using JSMR.Domain.Enums;
using JSMR.Domain.ValueObjects;
using JSMR.Infrastructure.Data;
using JSMR.Tests.Fixtures;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace JSMR.Tests.Ingestion.Japanese;
public class Basic_Insert_And_Update_Tests(MariaDbContainerFixture fixture) : JapaneseIngestionTestsBase(fixture)
{
[Fact]
public async Task Basic_Insert_And_Update_Test()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkIngest ingest = new()
{
MakerId = "RG1",
MakerName = "My Maker",
ProductId = "RJ1",
Title = "My Product",
Description = "My description",
Tags = ["Tag 1", "Tag 2"],
Creators = ["Creator 1"],
WishlistCount = 100,
Downloads = 0,
HasTrial = true,
HasChobit = false,
AgeRating = AgeRating.AllAges,
HasImage = false,
SupportedLanguages = [SupportedLanguage.Japanese],
SalesDate = null,
ExpectedDate = new DateOnly(2025, 2, 1)
};
DateTime dateTime = TokyoLocalToUtc(2025, 01, 15, 00, 00, 00);
await UpsertAsync(dbContext, dateTime, [ingest]);
Circle? circle = await dbContext.Circles.FirstOrDefaultAsync(v => v.MakerId == ingest.MakerId, TestContext.Current.CancellationToken);
circle.ShouldNotBeNull();
circle.Name.ShouldBe(ingest.MakerName);
VoiceWork? voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken);
voiceWork.ShouldNotBeNull();
voiceWork.ProductName.ShouldBe(ingest.Title);
voiceWork.Description.ShouldBe(ingest.Description);
voiceWork.WishlistCount.ShouldBe(ingest.WishlistCount);
voiceWork.Downloads.ShouldBe(ingest.Downloads);
voiceWork.HasTrial.ShouldBe(ingest.HasTrial);
voiceWork.HasChobit.ShouldBe(ingest.HasChobit);
voiceWork.Rating.ShouldBe((int)ingest.AgeRating);
voiceWork.HasImage.ShouldBe(ingest.HasImage);
voiceWork.SubtitleLanguage.ShouldBe((byte)Language.Japanese);
voiceWork.SalesDate.ShouldBeNull();
voiceWork.ExpectedDate.ShouldBe(ingest.ExpectedDate!.Value.ToDateTime(TimeOnly.MinValue));
foreach (string tagName in ingest.Tags)
{
Tag? tag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name == tagName, TestContext.Current.CancellationToken);
tag.ShouldNotBeNull();
VoiceWorkTag? voiceWorkTag = await dbContext.VoiceWorkTags.FirstOrDefaultAsync(vwt =>
vwt.VoiceWorkId == voiceWork.VoiceWorkId && vwt.TagId == tag.TagId, TestContext.Current.CancellationToken);
voiceWorkTag.ShouldNotBeNull();
}
foreach (string creatorName in ingest.Creators)
{
Creator? creator = await dbContext.Creators.FirstOrDefaultAsync(c => c.Name == creatorName, TestContext.Current.CancellationToken);
creator.ShouldNotBeNull();
VoiceWorkCreator? voiceWorkCreator = await dbContext.VoiceWorkCreators.FirstOrDefaultAsync(vwc =>
vwc.VoiceWorkId == voiceWork.VoiceWorkId && vwc.CreatorId == creator.CreatorId, TestContext.Current.CancellationToken);
voiceWorkCreator.ShouldNotBeNull();
}
foreach (SupportedLanguage supportedLanguage in ingest.SupportedLanguages)
{
VoiceWorkSupportedLanguage? voiceWorkSupportedLanauge = await dbContext.VoiceWorkSupportedLanguages.FirstOrDefaultAsync(x =>
x.VoiceWorkId == voiceWork.VoiceWorkId && x.Language == supportedLanguage.Code, TestContext.Current.CancellationToken);
voiceWorkSupportedLanauge.ShouldNotBeNull();
}
VoiceWorkIngest updatedIngest = ingest with
{
MakerName = "My Maker (Updated)",
Tags = ["Tag 1", "Not Tag 2"],
Creators = ["Not Creator 1"],
Downloads = 50,
HasChobit = true,
SupportedLanguages = [SupportedLanguage.Japanese, SupportedLanguage.English],
HasImage = true,
SalesDate = new DateOnly(2025, 2, 5),
ExpectedDate = null
};
DateTime updateDateTime = TokyoLocalToUtc(2025, 02, 03, 00, 00, 00);
await UpsertAsync(dbContext, dateTime, [updatedIngest]);
circle = await dbContext.Circles.FirstOrDefaultAsync(v => v.MakerId == updatedIngest.MakerId, TestContext.Current.CancellationToken);
circle.ShouldNotBeNull();
circle.Name.ShouldBe(updatedIngest.MakerName);
voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == updatedIngest.ProductId, TestContext.Current.CancellationToken);
voiceWork.ShouldNotBeNull();
voiceWork.ProductName.ShouldBe(updatedIngest.Title);
voiceWork.Description.ShouldBe(updatedIngest.Description);
voiceWork.WishlistCount.ShouldBe(updatedIngest.WishlistCount);
voiceWork.Downloads.ShouldBe(updatedIngest.Downloads);
voiceWork.HasTrial.ShouldBe(updatedIngest.HasTrial);
voiceWork.HasChobit.ShouldBe(updatedIngest.HasChobit);
voiceWork.Rating.ShouldBe((int)updatedIngest.AgeRating);
voiceWork.HasImage.ShouldBe(updatedIngest.HasImage);
voiceWork.SubtitleLanguage.ShouldBe((byte)Language.English);
voiceWork.SalesDate.ShouldBe(updatedIngest.SalesDate!.Value.ToDateTime(TimeOnly.MinValue));
voiceWork.ExpectedDate.ShouldBeNull();
foreach (string tagName in updatedIngest.Tags.Union(ingest.Tags))
{
Tag? tag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name == tagName, TestContext.Current.CancellationToken);
tag.ShouldNotBeNull();
VoiceWorkTag? voiceWorkTag = await dbContext.VoiceWorkTags.FirstOrDefaultAsync(vwt =>
vwt.VoiceWorkId == voiceWork.VoiceWorkId && vwt.TagId == tag.TagId, TestContext.Current.CancellationToken);
voiceWorkTag.ShouldNotBeNull();
}
foreach (string creatorName in updatedIngest.Creators.Union(ingest.Creators))
{
Creator? creator = await dbContext.Creators.FirstOrDefaultAsync(c => c.Name == creatorName, TestContext.Current.CancellationToken);
creator.ShouldNotBeNull();
VoiceWorkCreator? voiceWorkCreator = await dbContext.VoiceWorkCreators.FirstOrDefaultAsync(vwc =>
vwc.VoiceWorkId == voiceWork.VoiceWorkId && vwc.CreatorId == creator.CreatorId, TestContext.Current.CancellationToken);
voiceWorkCreator.ShouldNotBeNull();
}
foreach (SupportedLanguage supportedLanguage in updatedIngest.SupportedLanguages.Union(ingest.SupportedLanguages))
{
VoiceWorkSupportedLanguage? voiceWorkSupportedLanauge = await dbContext.VoiceWorkSupportedLanguages.FirstOrDefaultAsync(x =>
x.VoiceWorkId == voiceWork.VoiceWorkId && x.Language == supportedLanguage.Code, TestContext.Current.CancellationToken);
voiceWorkSupportedLanauge.ShouldNotBeNull();
}
}
}

View File

@@ -29,7 +29,7 @@ public class Fail_Attempted_Insert_With_Spam_Circle_Tests(MariaDbContainerFixtur
WishlistCount = 100, WishlistCount = 100,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
AgeRating = AgeRating.R18, AgeRating = AgeRating.R18,
HasImage = false, HasImage = false,
SupportedLanguages = [SupportedLanguage.Japanese], SupportedLanguages = [SupportedLanguage.Japanese],

View File

@@ -29,7 +29,7 @@ public class Fail_Attempted_Update_With_Decreased_Downloads_Tests(MariaDbContain
WishlistCount = 50, WishlistCount = 50,
Downloads = 10, Downloads = 10,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,

View File

@@ -29,7 +29,7 @@ public class Fail_Attempted_Update_With_Sales_Date_Reversal_Tests(MariaDbContain
WishlistCount = 50, WishlistCount = 50,
Downloads = 10, Downloads = 10,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,

View File

@@ -28,7 +28,7 @@ public class Insert_New_Release_And_Scan_Again_Later_Tests(MariaDbContainerFixtu
WishlistCount = 50, WishlistCount = 50,
Downloads = 10, Downloads = 10,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,

View File

@@ -29,7 +29,7 @@ public class Insert_New_Release_With_New_Tags_And_Creators_Tests(MariaDbContaine
WishlistCount = 50, WishlistCount = 50,
Downloads = 10, Downloads = 10,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,

View File

@@ -28,7 +28,7 @@ public class Insert_New_Upcoming_And_Scan_Again_Later_Tests(MariaDbContainerFixt
WishlistCount = 100, WishlistCount = 100,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,
HasImage = false, HasImage = false,
SupportedLanguages = [SupportedLanguage.Japanese], SupportedLanguages = [SupportedLanguage.Japanese],

View File

@@ -28,7 +28,7 @@ public class Insert_New_Upcoming_Release_Same_Day_Tests(MariaDbContainerFixture
WishlistCount = 100, WishlistCount = 100,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
AgeRating = AgeRating.AllAges, AgeRating = AgeRating.AllAges,
HasImage = false, HasImage = false,
SupportedLanguages = [SupportedLanguage.Japanese], SupportedLanguages = [SupportedLanguage.Japanese],

View File

@@ -29,7 +29,7 @@ public class Insert_New_Upcoming_With_Existing_Tags_And_Creators_Tests(MariaDbCo
WishlistCount = 250, WishlistCount = 250,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.R15, AgeRating = AgeRating.R15,

View File

@@ -0,0 +1,63 @@
using JSMR.Application.Scanning.Contracts;
using JSMR.Domain.Entities;
using JSMR.Domain.ValueObjects;
using JSMR.Infrastructure.Data;
using JSMR.Tests.Fixtures;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace JSMR.Tests.Ingestion.Japanese;
public class Update_Supported_Langauge_Tests(MariaDbContainerFixture container) : JapaneseIngestionTestsBase(container)
{
[Fact]
public async Task Update_Supported_Langauge()
{
VoiceWorkIngest ingest = new()
{
MakerId = "RG1",
MakerName = "The Maker",
ProductId = "RJ1",
Title = "Title",
Description = "Description",
SupportedLanguages = [SupportedLanguage.Japanese]
};
await using AppDbContext dbContext = await GetAppDbContextAsync();
DateTime currentDateTime = TokyoLocalToUtc(2025, 01, 05, 10, 0, 0);
await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), ingest, [SupportedLanguage.Japanese]);
// Add English
VoiceWorkIngest addSupportedLanguageIngest = ingest with
{
SupportedLanguages = [SupportedLanguage.Japanese, SupportedLanguage.English]
};
await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), addSupportedLanguageIngest, [SupportedLanguage.Japanese, SupportedLanguage.English]);
// Remove Japanese
VoiceWorkIngest removeSupportedLanguageIngest = ingest with
{
SupportedLanguages = [SupportedLanguage.English]
};
await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), removeSupportedLanguageIngest, [SupportedLanguage.English]);
}
private static async Task UpsertAndVerify(AppDbContext dbContext, DateTime dateTime, VoiceWorkIngest ingest, SupportedLanguage[] expectedSupportedLanguages)
{
await UpsertAsync(dbContext, dateTime, [ingest]);
VoiceWork? voiceWork = await dbContext.VoiceWorks
.Include(x => x.SupportedLanguages)
.SingleAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken);
voiceWork.ShouldNotBeNull();
string[] languageCodes = [.. voiceWork.SupportedLanguages.Select(x => x.Language).OrderBy(x => x)];
string[] expectedLanguageCode = [.. expectedSupportedLanguages.Select(x => x.Code).OrderBy(x => x)];
languageCodes.ShouldBe(expectedLanguageCode);
}
}

View File

@@ -26,7 +26,7 @@ public class Update_Upcoming_With_No_Expected_Date_Tests(MariaDbContainerFixture
WishlistCount = 250, WishlistCount = 250,
Downloads = 0, Downloads = 0,
HasTrial = false, HasTrial = false,
HasDLPlay = false, HasChobit = false,
StarRating = null, StarRating = null,
Votes = null, Votes = null,
AgeRating = AgeRating.R15, AgeRating = AgeRating.R15,
@@ -48,7 +48,7 @@ public class Update_Upcoming_With_No_Expected_Date_Tests(MariaDbContainerFixture
}; };
// Should be exactly the same // Should be exactly the same
await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), ingest, new DateTime(2025, 1, 21)); await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), updatedIngest, new DateTime(2025, 1, 21));
} }
private static async Task UpsertAndVerify(AppDbContext dbContext, DateTime dateTime, VoiceWorkIngest ingest, DateTime? expectedDate) private static async Task UpsertAndVerify(AppDbContext dbContext, DateTime dateTime, VoiceWorkIngest ingest, DateTime? expectedDate)

View File

@@ -0,0 +1,98 @@
using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Infrastructure.Integrations.Chobit;
using JSMR.Tests.Http;
using JSMR.Tests.Utilities;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using System.Net;
using System.Text;
namespace JSMR.Tests.Integrations.Chobit;
public class ChobitClientTests
{
private static async Task<string> ReadJsonResourceAsync(string resourceName)
{
return await ResourceHelper.ReadAsync($"JSMR.Tests.Integrations.Chobit.{resourceName}");
}
private static async Task<ChobitClient> GetChobitClientThatReturns(string resourceName)
{
string content = await ReadJsonResourceAsync(resourceName);
FakeHttpMessageHandler handler = new(request =>
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
};
});
HttpClient httpClient = new(handler)
{
BaseAddress = new Uri("https://fake-chobit.cc/")
};
var logger = Substitute.For<ILogger<ChobitClient>>();
var client = new ChobitClient(httpClient, logger);
return client;
}
[Fact]
public async Task Deserialize_Chobit_Sample_Info()
{
var client = await GetChobitClientThatReturns("Sample-Chobit-Result.jsonp");
var result = await client.GetSampleInfoAsync("RJ01430276", CancellationToken.None);
result.Count.ShouldBe(1);
result.Works.Length.ShouldBe(1);
ChobitWork work = result.Works[0];
work.WorkId.ShouldBe("70nh8");
work.DLSiteWorkId.ShouldBe("RJ01430276");
work.WorkName.ShouldBe("【ブルーアーカイブ】シグレ(温泉)ASMR溶けていく温度を交わして");
}
[Fact]
public async Task Deserialize_Chobit_Sample_Info_Collection()
{
var client = await GetChobitClientThatReturns("Sample-Chobit-Result-Collection.jsonp");
var result = await client.GetSampleInfoAsync(["RJ01430276"], CancellationToken.None);
result.Count.ShouldBe(1);
result.ShouldContainKey("RJ01430276");
result["RJ01430276"].Count.ShouldBe(1);
result["RJ01430276"].Works.Length.ShouldBe(1);
ChobitWork work = result["RJ01430276"].Works[0];
work.WorkId.ShouldBe("70nh8");
work.DLSiteWorkId.ShouldBe("RJ01430276");
work.WorkName.ShouldBe("【ブルーアーカイブ】シグレ(温泉)ASMR溶けていく温度を交わして");
}
[Fact]
public async Task Deserialize_Chobit_Sample_Info_No_Data()
{
var client = await GetChobitClientThatReturns("Sample-Chobit-Result-No-Data.jsonp");
var result = await client.GetSampleInfoAsync("RJ01585659", CancellationToken.None);
result.Count.ShouldBe(0);
result.Works.Length.ShouldBe(0);
}
[Fact]
public async Task Deserialize_Chobit_Sample_Info_Collection_No_Data()
{
var client = await GetChobitClientThatReturns("Sample-Chobit-Result-Collection-No-Data.jsonp");
var result = await client.GetSampleInfoAsync(["RJ01585659"], CancellationToken.None);
result.Count.ShouldBe(1);
result.ShouldContainKey("RJ01585659");
result["RJ01585659"].Count.ShouldBe(0);
result["RJ01585659"].Works.Length.ShouldBe(0);
}
}

View File

@@ -0,0 +1 @@
response({"RJ01585659":{"count":0,"works":[]}})

View File

@@ -0,0 +1 @@
response({"RJ01430276":{"count":1,"works":[{"work_id":"70nh8","dlsite_work_id":"RJ01430276","work_name":"\u3010\u30d6\u30eb\u30fc\u30a2\u30fc\u30ab\u30a4\u30d6\u3011\u30b7\u30b0\u30ec(\u6e29\u6cc9)ASMR\uff5e\u6eb6\u3051\u3066\u3044\u304f\u6e29\u5ea6\u3092\u4ea4\u308f\u3057\u3066\uff5e","work_name_kana":"\u30d6\u30eb\u30fc\u30a2\u30fc\u30ab\u30a4\u30d6\u30b7\u30b0\u30ec\u30aa\u30f3\u30bb\u30f3\u30a8\u30fc\u30a8\u30b9\u30a8\u30e0\u30a2\u30fc\u30eb\u30c8\u30b1\u30c6\u30a4\u30af\u30aa\u30f3\u30c9\u30f2\u30ab\u30ef\u30b7\u30c6","url":"https:\/\/chobit.cc\/70nh8\/vgj6safb","embed_url":"https:\/\/chobit.cc\/embed\/70nh8\/vgj6safb?dlsite=1","thumb":"https:\/\/media.dlsite.com\/chobit\/contents\/2507\/f1kkssulligowgkkcc0k80soo\/f1kkssulligowgkkcc0k80soo_cover.jpg?w=560\u0026h=420","mini_thumb":"https:\/\/media.dlsite.com\/chobit\/contents\/2507\/f1kkssulligowgkkcc0k80soo\/f1kkssulligowgkkcc0k80soo_cover.jpg?w=100\u0026h=100","file_type":"audio","embed_width":740,"embed_height":215}]}})

View File

@@ -0,0 +1 @@
response({"count":0,"works":[]})

View File

@@ -0,0 +1 @@
response({"count":1,"works":[{"work_id":"70nh8","dlsite_work_id":"RJ01430276","work_name":"\u3010\u30d6\u30eb\u30fc\u30a2\u30fc\u30ab\u30a4\u30d6\u3011\u30b7\u30b0\u30ec(\u6e29\u6cc9)ASMR\uff5e\u6eb6\u3051\u3066\u3044\u304f\u6e29\u5ea6\u3092\u4ea4\u308f\u3057\u3066\uff5e","work_name_kana":"\u30d6\u30eb\u30fc\u30a2\u30fc\u30ab\u30a4\u30d6\u30b7\u30b0\u30ec\u30aa\u30f3\u30bb\u30f3\u30a8\u30fc\u30a8\u30b9\u30a8\u30e0\u30a2\u30fc\u30eb\u30c8\u30b1\u30c6\u30a4\u30af\u30aa\u30f3\u30c9\u30f2\u30ab\u30ef\u30b7\u30c6","url":"https:\/\/chobit.cc\/70nh8\/vgj6safb","embed_url":"https:\/\/chobit.cc\/embed\/70nh8\/vgj6safb?dlsite=1","thumb":"https:\/\/media.dlsite.com\/chobit\/contents\/2507\/f1kkssulligowgkkcc0k80soo\/f1kkssulligowgkkcc0k80soo_cover.jpg?w=560\u0026h=420","mini_thumb":"https:\/\/media.dlsite.com\/chobit\/contents\/2507\/f1kkssulligowgkkcc0k80soo\/f1kkssulligowgkkcc0k80soo_cover.jpg?w=100\u0026h=100","file_type":"audio","embed_width":740,"embed_height":215}]})

View File

@@ -1,15 +1,19 @@
using JSMR.Application.Integrations.DLSite.Models; using JSMR.Application.Enums;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Domain.Enums; using JSMR.Domain.Enums;
using JSMR.Domain.ValueObjects; using JSMR.Domain.ValueObjects;
using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Integrations.DLSite; using JSMR.Infrastructure.Integrations.DLSite;
using JSMR.Infrastructure.Integrations.DLSite.Mapping; using JSMR.Infrastructure.Integrations.DLSite.Mapping;
using JSMR.Infrastructure.Integrations.DLSite.Models; using JSMR.Infrastructure.Integrations.DLSite.Models;
using JSMR.Tests.Extensions; using JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
using JSMR.Tests.Http;
using JSMR.Tests.Utilities; using JSMR.Tests.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Shouldly; using Shouldly;
using System.Net;
using System.Text;
namespace JSMR.Tests.Integrations.DLSite; namespace JSMR.Tests.Integrations.DLSite;
@@ -20,17 +24,47 @@ public class DLSiteClientTests
return await ResourceHelper.ReadAsync($"JSMR.Tests.Integrations.DLSite.{resourceName}"); return await ResourceHelper.ReadAsync($"JSMR.Tests.Integrations.DLSite.{resourceName}");
} }
private static async Task<(DLSiteClient Client, FakeHttpMessageHandler Handler)> GetDLSiteClientThatReturns(string resourceName)
{
string content = await ReadJsonResourceAsync(resourceName);
FakeHttpMessageHandler handler = new(_ =>
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
};
});
HttpClient httpClient = new(handler)
{
BaseAddress = new Uri("https://www.fake-dlsite.com/")
};
var logger = Substitute.For<ILogger<DLSiteClient>>();
var client = new DLSiteClient(httpClient, logger);
return (client, handler);
}
[Fact]
public async Task GetVoiceWorkDetailsAsync_Should_Call_Expected_Request()
{
var (client, handler) = await GetDLSiteClientThatReturns("Product-Info.json");
await client.GetVoiceWorkDetailsAsync(["RJ01230163", "RJ01536422"], CancellationToken.None);
handler.Requests.Count.ShouldBe(1);
HttpRequestMessage httpRequest = handler.Requests[0];
httpRequest.Method.ShouldBe(HttpMethod.Get);
httpRequest.ShouldHavePath("/maniax/product/info/ajax");
httpRequest.ShouldHaveQueryParam("product_id", "RJ01230163,RJ01536422");
}
[Fact] [Fact]
public async Task Deserialize_Product_Info_Collection() public async Task Deserialize_Product_Info_Collection()
{ {
string productInfoJson = await ReadJsonResourceAsync("Product-Info.json"); var (client, handler) = await GetDLSiteClientThatReturns("Product-Info.json");
IHttpService httpService = Substitute.For<IHttpService>();
httpService.ReturnsContent(productInfoJson);
var logger = Substitute.For<ILogger<DLSiteClient>>();
var client = new DLSiteClient(httpService, logger);
var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163", "RJ01536422"], CancellationToken.None); var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163", "RJ01536422"], CancellationToken.None);
result.Count.ShouldBe(2); result.Count.ShouldBe(2);
@@ -127,4 +161,78 @@ public class DLSiteClientTests
secondVoiceWorkDetails.Translation.Language.ShouldBe(Language.English); secondVoiceWorkDetails.Translation.Language.ShouldBe(Language.English);
secondVoiceWorkDetails.Translation.Kind.ShouldBe(TranslationKind.Official | TranslationKind.Recommended); secondVoiceWorkDetails.Translation.Kind.ShouldBe(TranslationKind.Official | TranslationKind.Recommended);
} }
[Fact]
public async Task GetReleasedWorksAsync_Should_Call_Expected_Request()
{
var (client, handler) = await GetDLSiteClientThatReturns("Released-Works.json");
ReleasedWorksRequest request = new(Locale.English, new(2025, 1, 1), 1);
await client.GetReleasedWorksAsync(request, CancellationToken.None);
handler.Requests.Count.ShouldBe(1);
HttpRequestMessage httpRequest = handler.Requests[0];
httpRequest.Method.ShouldBe(HttpMethod.Get);
httpRequest.ShouldHavePath("/maniax/new/work/api");
httpRequest.ShouldHaveQueryParam("locale", "en-us");
httpRequest.ShouldHaveQueryParam("date", "2025-01-01");
httpRequest.ShouldHaveQueryParam("period", "1");
}
[Fact]
public async Task Map_Basic_Released_Work_Collection()
{
NewWorksApiResponse response = new()
{
Meta = new()
{
Code = 200
},
Data = new()
{
Products =
[
new()
{
Id = "RG1",
Name = "The Title",
NameMasked = "Masked Title",
Description = "The description",
DescriptionMasked = "The masked description"
}
]
}
};
ReleasedWorksCollection collection = DLSiteReleasedWorksMapper.Map(response);
collection.Count.ShouldBe(1);
collection.ShouldContainKey("RG1");
ReleasedWork releasedWork = collection["RG1"];
releasedWork.ProductId.ShouldBe("RG1");
releasedWork.Title.ShouldBe("The Title");
releasedWork.MaskedTitle.ShouldBe("Masked Title");
releasedWork.Description.ShouldBe("The description");
releasedWork.MaskedDescription.ShouldBe("The masked description");
}
[Fact]
public async Task Deserialize_Released_Work_Collection()
{
var (client, handler) = await GetDLSiteClientThatReturns("Released-Works.json");
var request = new ReleasedWorksRequest(Locale.English, new(2025, 1, 1), 1);
var result = await client.GetReleasedWorksAsync(request, CancellationToken.None);
result.Count.ShouldBe(13);
result.ShouldContainKey("RJ01588345");
ReleasedWork releasedWork = result["RJ01588345"];
releasedWork.Title.ShouldBe("魔都一贅肉のスゴイデブ");
releasedWork.MaskedTitle.ShouldBe("魔都一贅肉のスゴイデブ");
releasedWork.Description.ShouldBe("圧巻の超連続・激太りご褒美パラダイス、ここに全24ページ描き下ろし太→激太への肥満化シークエンスが11作収録餌付け、自己肥育、お風呂、縛り…などなど多種多様の太り方でご褒美を堪能せよ");
releasedWork.MaskedDescription.ShouldBe("圧巻の超連続・激太りご褒美パラダイス、ここに全24ページ描き下ろし太→激太への肥満化シークエンスが11作収録餌付け、自己肥育、お風呂、縛り…などなど多種多様の太り方でご褒美を堪能せよ");
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,11 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Integrations\Chobit\Sample-Chobit-Result-Collection-No-Data.jsonp" />
<EmbeddedResource Include="Integrations\Chobit\Sample-Chobit-Result-No-Data.jsonp" />
<EmbeddedResource Include="Integrations\Chobit\Sample-Chobit-Result-Collection.jsonp" />
<EmbeddedResource Include="Integrations\Chobit\Sample-Chobit-Result.jsonp" />
<EmbeddedResource Include="Integrations\DLSite\Released-Works.json" />
<EmbeddedResource Include="Integrations\DLSite\Product-Info.json" /> <EmbeddedResource Include="Integrations\DLSite\Product-Info.json" />
<EmbeddedResource Include="Scanning\English-Page-Updated.html" /> <EmbeddedResource Include="Scanning\English-Page-Updated.html" />
<EmbeddedResource Include="Scanning\Japanese-Page-Updated.html" /> <EmbeddedResource Include="Scanning\Japanese-Page-Updated.html" />
@@ -21,17 +26,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.0"> <PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" /> <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testcontainers" Version="4.10.0" /> <PackageReference Include="Testcontainers" Version="4.11.0" />
<PackageReference Include="Testcontainers.MariaDb" Version="4.10.0" /> <PackageReference Include="Testcontainers.MariaDb" Version="4.11.0" />
<PackageReference Include="Testcontainers.XunitV3" Version="4.10.0" /> <PackageReference Include="Testcontainers.XunitV3" Version="4.11.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -1,5 +1,6 @@
using JSMR.Application.Integrations.DLSite.Models; using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Application.Integrations.Ports; using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts; using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports; using JSMR.Application.Scanning.Ports;
using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Http;
@@ -141,10 +142,21 @@ public class VoiceWorkScannerTests
} }
}; };
ChobitResultCollection chobitResultCollection = new()
{
{
"RJ1",
new ChobitResult()
{
Count = 0
}
}
};
dlsiteClient.GetVoiceWorkDetailsAsync(Arg.Any<string[]>(), CancellationToken.None) dlsiteClient.GetVoiceWorkDetailsAsync(Arg.Any<string[]>(), CancellationToken.None)
.Returns(Task.FromResult(detailCollection)); .Returns(Task.FromResult(detailCollection));
VoiceWorkIngest ingest = VoiceWorkIngest.From(scannedWorks[0], detailCollection["RJ1"]); VoiceWorkIngest ingest = VoiceWorkIngest.From(scannedWorks[0], detailCollection["RJ1"], chobitResultCollection["RJ1"]);
// TODO: Test other fields // TODO: Test other fields
ingest.Title.ShouldBe("Product Title"); ingest.Title.ShouldBe("Product Title");

View File

@@ -54,7 +54,7 @@ public class CreatorSearchProviderTests(CreatorSearchProviderFixture fixture) :
{ {
var options = new SearchOptions<CreatorSearchCriteria, CreatorSortField>() var options = new SearchOptions<CreatorSearchCriteria, CreatorSortField>()
{ {
SortOptions = [new(CreatorSortField.Favorite, SortDirection.Ascending)] SortOptions = [new(CreatorSortField.Favorite, SortDirection.Descending)]
}; };
var result = await SearchAsync(options); var result = await SearchAsync(options);
@@ -67,7 +67,7 @@ public class CreatorSearchProviderTests(CreatorSearchProviderFixture fixture) :
{ {
var options = new SearchOptions<CreatorSearchCriteria, CreatorSortField>() var options = new SearchOptions<CreatorSearchCriteria, CreatorSortField>()
{ {
SortOptions = [new(CreatorSortField.Blacklisted, SortDirection.Ascending)] SortOptions = [new(CreatorSortField.Blacklisted, SortDirection.Descending)]
}; };
var result = await SearchAsync(options); var result = await SearchAsync(options);

View File

@@ -84,4 +84,30 @@ public class TagSearchProviderTests(TagSearchProviderFixture fixture) : IClassFi
result.TotalItems.ShouldBe(1); result.TotalItems.ShouldBe(1);
result.Items.ShouldContain(tagView => tagView.EnglishName == "Heartwarming"); result.Items.ShouldContain(tagView => tagView.EnglishName == "Heartwarming");
} }
[Fact]
public async Task Filter_None_Sort_Favorite_Descending()
{
var options = new SearchOptions<TagSearchCriteria, TagSortField>()
{
SortOptions = [new(TagSortField.Favorite, SortDirection.Descending)]
};
var result = await SearchAsync(options);
result.Items[0].EnglishName.ShouldBe("Heartwarming");
}
[Fact]
public async Task Filter_None_Sort_Blacklisted_Descending()
{
var options = new SearchOptions<TagSearchCriteria, TagSortField>()
{
SortOptions = [new(TagSortField.Blacklisted, SortDirection.Descending)]
};
var result = await SearchAsync(options);
result.Items[0].EnglishName.ShouldBe("Tsundere");
}
} }

View File

@@ -1,4 +1,5 @@
using JSMR.Domain.Enums; using JSMR.Domain.Entities;
using JSMR.Domain.Enums;
using JSMR.Infrastructure.Data; using JSMR.Infrastructure.Data;
using JSMR.Tests.Fixtures; using JSMR.Tests.Fixtures;
@@ -22,6 +23,14 @@ public sealed class VoiceWorkSearchProviderFixture(MariaDbContainerFixture conta
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, 11), PlannedReleaseDate = new(2025, 1, 13), WishlistCount = 10000, SubtitleLanguage = (byte)Language.English, Rating = (byte)AgeRating.R15, AIGeneration = (byte)AIGeneration.None } 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, 11), PlannedReleaseDate = new(2025, 1, 13), WishlistCount = 10000, SubtitleLanguage = (byte)Language.English, Rating = (byte)AgeRating.R15, AIGeneration = (byte)AIGeneration.None }
); );
context.VoiceWorkSupportedLanguages.AddRange(
new() { VoiceWorkSupportedLanguageId = 1, VoiceWorkId = 1, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 2, VoiceWorkId = 2, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 3, VoiceWorkId = 3, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 4, VoiceWorkId = 4, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 5, VoiceWorkId = 5, Language = "ENG" }
);
context.Tags.AddRange( context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" }, new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "OL" }, new() { TagId = 2, Name = "OL" },

View File

@@ -0,0 +1,288 @@
using JSMR.Application.Enums;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts;
using JSMR.Infrastructure.Scanning;
using NSubstitute;
using Shouldly;
namespace JSMR.Tests.Unit;
public class ReleasedWorksProviderTests
{
private readonly IDLSiteClient _dlsiteClient = Substitute.For<IDLSiteClient>();
[Fact]
public async Task GetReleasedWorksAsync_WhenNoSalesDates_ReturnsEmptyAndDoesNotCallClient()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = null,
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result = await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.ShouldBeEmpty();
await _dlsiteClient.DidNotReceiveWithAnyArgs().GetReleasedWorksAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task GetReleasedWorksAsync_WhenRangeIsUnder60Days_CallsClientOnce()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 10),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 1, 20),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksCollection apiResult = new()
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "English title 1",
Description = "Description",
MaskedTitle = "English title 1",
MaskedDescription = "Description",
},
["RJ002"] = new ReleasedWork
{
ProductId = "RJ002",
Title = "English title 2",
Description = "Description",
MaskedTitle = "English title 2",
MaskedDescription = "Description",
}
};
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns(apiResult);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result =
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.Keys.ShouldBe(["RJ001", "RJ002"], ignoreOrder: true);
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Locale == Locale.English &&
x.Date == new DateOnly(2024, 1, 20) &&
x.Period == 11),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetReleasedWorksAsync_WhenRangeExceeds60Days_SplitsIntoMultipleRequests()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 1),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 3, 5),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns([]);
ReleasedWorksProvider provider = new(_dlsiteClient);
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Date == new DateOnly(2024, 2, 29) &&
x.Period == 60),
Arg.Any<CancellationToken>());
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Date == new DateOnly(2024, 3, 5) &&
x.Period == 5),
Arg.Any<CancellationToken>());
await _dlsiteClient.Received(2).GetReleasedWorksAsync(
Arg.Any<ReleasedWorksRequest>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetReleasedWorksAsync_FiltersOutProductsNotInScanResult()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 10),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksCollection apiResult = new()
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "Keep me",
Description = "Description",
MaskedTitle = "Keep me",
MaskedDescription = "Description",
},
["RJ999"] = new ReleasedWork
{
ProductId = "RJ999",
Title = "Ignore me",
Description = "Description",
MaskedTitle = "Ignore me",
MaskedDescription = "Description",
},
};
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns(apiResult);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result =
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.Keys.ShouldBe(["RJ001"]);
}
[Fact]
public async Task GetReleasedWorksAsync_WhenSameProductReturnedTwice_KeepsFirstResult()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 1),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 3, 5),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
_dlsiteClient
.GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x => x.Period == 60),
Arg.Any<CancellationToken>())
.Returns(new ReleasedWorksCollection
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "First",
Description = "Description",
MaskedTitle = "First",
MaskedDescription = "Description",
}
});
_dlsiteClient
.GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x => x.Period == 5),
Arg.Any<CancellationToken>())
.Returns(new ReleasedWorksCollection
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "Second",
Description = "Description",
MaskedTitle = "Second",
MaskedDescription = "Description",
}
});
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result = await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result["RJ001"].Title.ShouldBe("First");
}
}

View File

@@ -3,15 +3,18 @@
@if (string.IsNullOrWhiteSpace(Url)) @if (string.IsNullOrWhiteSpace(Url))
{ {
<div class="@GetClasses()" @onclick="@OnClickAsync"> <div class="@GetClasses()" @onclick="@OnClickAsync">
@if (Graphic != null) @if (Graphic != null && Graphic != Enums.Graphic.None)
{ {
<Icon Graphic="@Graphic.Value" <Icon Graphic="@Graphic.Value"
Varient="@(IconVarient ?? Enums.IconVarient.None)" Varient="@(IconVarient ?? Enums.IconVarient.None)"
Size="@(IconSize ?? Enums.SizeVarient.Small)" Size="@(IconSize ?? Enums.SizeVarient.Small)"
Color="@Color"> UseCurrentColor>
</Icon> </Icon>
} }
@if (ChildContent is not null)
{
<span>@ChildContent</span> <span>@ChildContent</span>
}
</div> </div>
} }
else else
@@ -23,10 +26,13 @@ else
Graphic="@Graphic.Value" Graphic="@Graphic.Value"
Varient="@(IconVarient ?? Enums.IconVarient.None)" Varient="@(IconVarient ?? Enums.IconVarient.None)"
Size="@(IconSize ?? Enums.SizeVarient.Small)" Size="@(IconSize ?? Enums.SizeVarient.Small)"
Color="@Color"> UseCurrentColor>
</Icon> </Icon>
} }
@if (ChildContent is not null)
{
<span>@ChildContent</span> <span>@ChildContent</span>
}
</a> </a>
} }
@@ -62,6 +68,12 @@ else
[Parameter] [Parameter]
public EventCallback Click { get; set; } public EventCallback Click { get; set; }
[Parameter]
public bool IsClickable { get; set; }
[Parameter]
public bool ThickBorder { get; set; }
private string GetClasses() private string GetClasses()
{ {
string color = Color.ToString().ToLower(); string color = Color.ToString().ToLower();
@@ -72,6 +84,17 @@ else
$"color-{color}" $"color-{color}"
]; ];
// Experimental
if (ChildContent is null)
{
classNames.Add("j-chip-icon-only");
}
if (ThickBorder)
{
classNames.Add("j-chip-thick-border");
}
switch (Varient) switch (Varient)
{ {
case ElementVarient.Filled: case ElementVarient.Filled:
@@ -94,7 +117,12 @@ else
break; break;
} }
if (Click.HasDelegate || string.IsNullOrWhiteSpace(Url) == false) if (Varient == ElementVarient.Filled && Tone == ToneVarient.Tint)
{
classNames.Add($"varient-tint");
}
if (Click.HasDelegate || string.IsNullOrWhiteSpace(Url) == false || IsClickable)
{ {
classNames.Add("is-clickable"); classNames.Add("is-clickable");
} }

View File

@@ -23,7 +23,7 @@
if (Tag.IsBlacklisted) if (Tag.IsBlacklisted)
{ {
return ColorVarient.Pink; return ColorVarient.Red;
} }
return ColorVarient.Secondary; return ColorVarient.Secondary;

View File

@@ -1,6 +1,6 @@
@using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Enums
<div class="@GetIconClasses()"></div> <div class="@GetIconClasses()" style="@GetStyle()"></div>
@code { @code {
[Parameter] [Parameter]
@@ -15,6 +15,9 @@
[Parameter] [Parameter]
public ColorVarient Color { get; set; } public ColorVarient Color { get; set; }
[Parameter]
public bool UseCurrentColor { get; set; }
private string GetIconClasses() private string GetIconClasses()
{ {
string graphic = Varient == IconVarient.None string graphic = Varient == IconVarient.None
@@ -26,9 +29,19 @@
$"j-icon", $"j-icon",
$"j-icon-{graphic}", $"j-icon-{graphic}",
$"size-{Size.ToString().ToLower()}", $"size-{Size.ToString().ToLower()}",
$"background-color-{Color.ToString().ToLower()}" //$"background-color-{Color.ToString().ToLower()}"
]; ];
if (!UseCurrentColor)
{
classNames.Add($"background-color-{Color.ToString().ToLower()}");
}
return string.Join(" ", classNames); return string.Join(" ", classNames);
} }
private string? GetStyle()
{
return UseCurrentColor ? "background-color: currentColor;" : null;
}
} }

View File

@@ -0,0 +1,113 @@
@using JSMR.UI.Blazor.Enums
<div class="pagination">
<div>
<label>@IndexInfo</label>
</div>
@* <MudPagination class="pager" ShowFirstButton="true" ShowLastButton="true" Count="@((int)Math.Ceiling((decimal)TotalItems / (decimal)PageSize))" Selected="@PageNumber" SelectedChanged="OnSelectedChanged" /> *@
<AntDesign.Pagination ShowSizeChanger="false" Total="TotalItems" Current="PageNumber" PageSize="PageSize" OnChange="OnPaginationChanged"></AntDesign.Pagination>
<div class="page-sizes">
@RightContent
<div class="page-size-select">
<AntDesign.Select TItem="PageSizeItem"
TItemValue="int"
DataSource="PageSizes3"
LabelName="Text"
ValueName="Value"
Value="PageSize"
ValueChanged="OnPageSizeChanged">
</AntDesign.Select>
</div>
@* <BitDropdown Items="PageSizes2"
Style="min-width: 7rem"
Placeholder="Select..."
TItem="BitDropdownItem<int>"
TValue="int"
Value="PageSize"
ValueChanged="OnPageSizeChanged">
<PrefixTemplate>
<InputPrefix Graphic="Graphic.Grid" Tooltip="Page Size"></InputPrefix>
</PrefixTemplate>
</BitDropdown> *@
</div>
</div>
<style>
.pagination {
padding: 16px 16px;
background: transparent;
background: #141414;
}
.ant-pagination {
display: flex;
align-items: center;
justify-content: center;
}
.page-size-select {
width: 12em;
}
</style>
@code {
[Parameter]
public int PageNumber { get; set; }
[Parameter]
public EventCallback<int> PageNumberChanged { get; set; }
[Parameter]
public int[] PageSizes { get; set; } = [5, 10, 25, 50, 100];
List<BitDropdownItem<int>> PageSizes2 => [.. PageSizes.Select(x => new BitDropdownItem<int>() { Text = x.ToString(), Value = x })];
List<PageSizeItem> PageSizes3 => [.. PageSizes.Select(x => new PageSizeItem() { Text = $"{x} items / page", Value = x })];
[Parameter]
public int PageSize { get; set; }
[Parameter]
public EventCallback<int> PageSizeChanged { get; set; }
[Parameter]
public int TotalItems { get; set; }
[Parameter]
public EventCallback<int> TotalItemsChanged { get; set; }
[Parameter]
public RenderFragment? RightContent { get; set; }
public string IndexInfo => TotalItems == 0 ? "No items" : $"{StartIndex.ToString("n0")} - {EndIndex.ToString("n0")} of {TotalItems.ToString("n0")} items";
public int StartIndex => (PageNumber - 1) * PageSize + 1;
public int EndIndex => PageNumber * PageSize < TotalItems ? PageNumber * PageSize : TotalItems;
private async Task OnSelectedChanged(int newPage)
{
PageNumber = newPage;
await PageNumberChanged.InvokeAsync(newPage);
}
private async Task OnPageSizeChanged(int newPageSize)
{
PageSize = newPageSize;
await PageSizeChanged.InvokeAsync(newPageSize);
}
private async Task OnPaginationChanged(AntDesign.PaginationEventArgs args)
{
int newPage = args.Page;
PageNumber = newPage;
await PageNumberChanged.InvokeAsync(newPage);
}
public class PageSizeItem
{
public string? Text { get; set; }
public int Value { get; set; }
}
}

View File

@@ -1,4 +1,7 @@
@using JSMR.Application.VoiceWorks.Queries.Search @using AntDesign
@using JSMR.Application.VoiceWorks.Commands.Delete
@using JSMR.Application.VoiceWorks.Commands.SetFavorite
@using JSMR.Application.VoiceWorks.Queries.Search
@using JSMR.Domain.Enums @using JSMR.Domain.Enums
@using JSMR.UI.Blazor.Components.Chips @using JSMR.UI.Blazor.Components.Chips
@using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Enums
@@ -7,6 +10,10 @@
@using System.Globalization @using System.Globalization
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@inject VoiceWorksClient Client
@inject MessageService MessageService
@inject ModalService ModalService
<div class=@GetCardClasses(Product)> <div class=@GetCardClasses(Product)>
<div class="j-voice-work-image-container"> <div class="j-voice-work-image-container">
<JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")" FallbackSource="@ImageUrlProvider.GetImageUrl(Product, "main", "webp")"></JImage> <JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")" FallbackSource="@ImageUrlProvider.GetImageUrl(Product, "main", "webp")"></JImage>
@@ -15,39 +22,52 @@
<div class="j-product-title"> <div class="j-product-title">
<a href="@Product.ProductUrl" target="_blank">@Product.ProductName</a> <a href="@Product.ProductUrl" target="_blank">@Product.ProductName</a>
</div> </div>
<div class="j-product-contributors"> <BitStack Horizontal="true" Gap="0.5em" AutoHeight Wrap>
<span class="j-circle"> <Chip Graphic="Graphic.Circle" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html")>@Product.Maker</Chip>
<MudChip T="string"
Href=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html") @if (Product.OriginalCircle is not null)
Target="_blank" {
Variant="MudBlazor.Variant.Filled" <Chip Graphic="Graphic.Circle" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.OriginalCircle.MakerId}.html")>@Product.OriginalCircle.Name</Chip>
Icon="@Icons.Material.Outlined.Circle">@Product.Maker</MudChip> }
@* <CircleChip Circle="@Product.Circle"></CircleChip> *@
@foreach (var creator in Product.Creators) @foreach (var creator in Product.Creators)
{ {
<MudChip T="string" <Chip Graphic="Graphic.Person" Varient="ElementVarient.Outlined" IconVarient="IconVarient.Fill" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")>@creator.Name</Chip>
Href=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")
Target="_blank"
Variant="MudBlazor.Variant.Filled"
Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip>
@* <CreatorChip Creator="@creator"></CreatorChip> *@
} }
</span> </BitStack>
</div> <BitStack Horizontal="true" Gap="0.5em" AutoHeight Wrap>
@foreach (string supportedLanguage in Product.SupportedLanguages)
{
<Chip Graphic="Graphic.Globe" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary">@GetSupportedLanguageDescription(supportedLanguage)</Chip>
}
@if (Product.OriginalProductId is not null)
{
<Chip Graphic="Graphic.Translate" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary">Translation</Chip>
}
@if (Product.Rating == AgeRating.AllAges)
{
<Chip Graphic="Graphic.Age" Color="ColorVarient.Green" Varient="ElementVarient.Outlined">All Ages</Chip>
}
else if (Product.Rating == AgeRating.R15)
{
<Chip Graphic="Graphic.Age" Color="ColorVarient.Blue" Varient="ElementVarient.Outlined">R-15</Chip>
}
@if (Product.HasTrial || Product.HasChobit)
{
<Chip Graphic="Graphic.Download" Color="ColorVarient.Yellow" Varient="ElementVarient.Outlined">Trial</Chip>
}
@if (Product.Favorite)
{
<Chip Graphic="Graphic.Star" Color="ColorVarient.Teal" Varient="ElementVarient.Outlined">Favorite</Chip>
}
</BitStack>
<div class="j-product-description">@Product.Description</div> <div class="j-product-description">@Product.Description</div>
<div class="j-tags"> <div class="j-tags">
@foreach (var tag in Product.Tags) @foreach (var tag in Product.Tags)
{ {
@* <div class="j-tag">@tag.Name</div> *@
<ProductTag Tag="tag"></ProductTag> <ProductTag Tag="tag"></ProductTag>
} }
</div> </div>
@* <div class="j-tags">
@foreach (var tag in Product.Tags)
{
<TagChip Tag="tag"></TagChip>
}
</div> *@
</div> </div>
<div class="j-voice-work-info"> <div class="j-voice-work-info">
<div class="j-release-date-container"> <div class="j-release-date-container">
@@ -70,20 +90,45 @@
<BitStack Horizontal="true" Gap="0.5rem" VerticalAlign="BitAlignment.End" HorizontalAlign="BitAlignment.End"> <BitStack Horizontal="true" Gap="0.5rem" VerticalAlign="BitAlignment.End" HorizontalAlign="BitAlignment.End">
@if (Product.IsValid != true) @if (Product.IsValid != true)
{ {
<ProductIndicator Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Color="ColorVarient.Orange" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Color="ColorVarient.Orange" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Varient="ElementVarient.Outlined" Color="ColorVarient.Orange" ThickBorder></Chip>
} }
@if (Product.OriginalProductId is not null) @if (Product.OriginalProductId is not null)
{ {
<ProductIndicator Graphic="Graphic.Translate" Color="ColorVarient.Primary" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Translate" Color="ColorVarient.Primary" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Translate" Varient="ElementVarient.Outlined" Color="ColorVarient.Primary" ThickBorder></Chip>
} }
@if (Product.Favorite) @if (Product.Favorite)
{ {
<ProductIndicator Graphic="Graphic.Star" Color="ColorVarient.Pink" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Star" Color="ColorVarient.Pink" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Star" Varient="ElementVarient.Outlined" Color="ColorVarient.Pink" ThickBorder></Chip>
} }
@if (Product.HasTrial || Product.HasChobit) @if (Product.HasTrial || Product.HasChobit)
{ {
<ProductIndicator Graphic="Graphic.Headphones" Color="ColorVarient.Blue" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Headphones" Color="ColorVarient.Blue" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Headphones" Varient="ElementVarient.Outlined" Color="ColorVarient.Blue" ThickBorder></Chip>
} }
<Dropdown Trigger="@([Trigger.Click])">
<Overlay>
<Menu Selectable="false">
@if (!Product.Favorite)
{
<MenuItem OnClick="(e) => SetFavorite(true)">Add to Favorites</MenuItem>
}
@if (Product.Favorite)
{
<MenuItem OnClick="(e) => SetFavorite(false)">Remove from Favorites</MenuItem>
}
@if (Product.IsValid != true)
{
<MenuItem OnClick="(e) => Delete()">Delete</MenuItem>
}
</Menu>
</Overlay>
<ChildContent>
<Chip Graphic="Graphic.Pencil" Varient="ElementVarient.Outlined" Color="ColorVarient.Surface" IsClickable ThickBorder></Chip>
</ChildContent>
</Dropdown>
</BitStack> </BitStack>
</div> </div>
</div> </div>
@@ -92,6 +137,9 @@
[Parameter] [Parameter]
public required VoiceWorkSearchResult Product { get; set; } public required VoiceWorkSearchResult Product { get; set; }
[Parameter]
public EventCallback ProductDeleted { get; set; }
private string GetCardClasses(VoiceWorkSearchResult voiceWork) private string GetCardClasses(VoiceWorkSearchResult voiceWork)
{ {
List<string> classNames = ["j-card", "j-voice-work-card"]; List<string> classNames = ["j-card", "j-voice-work-card"];
@@ -149,4 +197,87 @@
return "jp"; return "jp";
} }
} }
private string GetSupportedLanguageDescription(string code)
{
switch (code)
{
case "JPN": return "Japanese";
case "ENG": return "English";
case "CHI_HANS": return "Chinese (Simplified)";
case "CHI_HANT": return "Chinese (Traditional)";
case "KO_KR": return "Korean";
default: return code;
}
}
private async Task SetFavorite(bool value)
{
SetVoiceWorkFavoriteRequest request = new(
VoiceWorkId: Product.VoiceWorkId,
IsFavorite: value
);
SetVoiceWorkFavoriteResponse? response = await Client.SetVoiceWorkFavoriteeAsync(request);
if (response is not null)
{
Product.Favorite = response.IsFavorite;
}
//await InvokeAsync(StateHasChanged);
MessageConfig messageConfig = new()
{
Content = $"Product '{Product.ProductName}' has been {(Product.Favorite ? "added to your favorites" : "removed from your favorites")}.",
Type = MessageType.Success
};
_ = MessageService.OpenAsync(messageConfig);
}
private async Task Delete()
{
RenderFragment icon = @<AntDesign.Icon Type="@IconType.Outline.ExclamationCircle" />;
AntDesign.ConfirmOptions options = new()
{
Title = "Are you sure you want to delete the following product?",
Icon = icon,
Content = Product.ProductName,
Centered = true,
OnOk = async (e) =>
{
DeleteVoiceWorkRequest request = new(
VoiceWorkIds: [Product.VoiceWorkId]
);
try
{
DeleteVoiceWorkResponse? response = await Client.DeleteVoiceWorkAsync(request);
if (response is null || response.Results[Product.VoiceWorkId] != DeleteVoiceWorkStatus.Deleted)
return;
await ProductDeleted.InvokeAsync();
}
catch (Exception ex)
{
AntDesign.ConfirmOptions errorOptions = new()
{
Title = "Unable to delete product",
Content = "Something went wrong while deleting this product. The product was not deleted. Check the API logs for details.",
Centered = true,
//Width = "70vw",
};
await ModalService.ErrorAsync(errorOptions);
e.Cancel = true;
}
}
};
await ModalService.ConfirmAsync(options);
}
} }

View File

@@ -13,7 +13,7 @@ else
<div class="j-product-items-container"> <div class="j-product-items-container">
@foreach (var product in Products) @foreach (var product in Products)
{ {
<JProduct Product="@product"></JProduct> <JProduct Product="@product" ProductDeleted="OnProductDeleted"></JProduct>
} }
</div> </div>
} }
@@ -21,4 +21,12 @@ else
@code { @code {
[Parameter] [Parameter]
public VoiceWorkSearchResult[]? Products { get; set; } public VoiceWorkSearchResult[]? Products { get; set; }
[Parameter]
public EventCallback ProductDeleted { get; set; }
private async Task OnProductDeleted()
{
await ProductDeleted.InvokeAsync();
}
} }

View File

@@ -0,0 +1,19 @@
@inherits FeedbackComponent<SetEnglishTagNameModel>
@using AntDesign
@using JSMR.UI.Blazor.Models
<Form Model="@Options">
<Input @bind-Value="@Options.UpdatedEnglishTagName" Placeholder="English Name" AutoFocus />
</Form>
@code {
[CascadingParameter]
public ModalRef ModalRef { get; set; } = default!;
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
{
await base.OnFeedbackOkAsync(args);
await CloseFeedbackAsync();
}
}

View File

@@ -1,11 +1,13 @@
@using JSMR.Application.Tags.Queries.Search.Contracts @using JSMR.Application.Tags.Queries.Search.Contracts
@using JSMR.Application.VoiceWorks.Queries.Search @using JSMR.Application.VoiceWorks.Queries.Search
@using JSMR.UI.Blazor.Enums
@using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Filters
@using JSMR.UI.Blazor.Services @using JSMR.UI.Blazor.Services
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
<a class="@Classes" @onclick="@OnClick"><Icon Graphic="Enums.Graphic.Tag" Color="Enums.ColorVarient.Primary"></Icon>@Tag.Name</a> @* <a class="@Classes" @onclick="@OnClick"><Icon Graphic="Enums.Graphic.Tag" Color="Enums.ColorVarient.Primary"></Icon>@Tag.Name</a> *@
@* <MudChip T="string" Icon="@Icons.Material.Outlined.Tag" @onclick="@OnClick" Variant="@MudBlazor.Variant.Filled" Color="@MudBlazor.Color.Surface">@Tag.Name</MudChip> *@ @* <MudChip T="string" Icon="@Icons.Material.Outlined.Tag" @onclick="@OnClick" Variant="@MudBlazor.Variant.Filled" Color="@MudBlazor.Color.Surface">@Tag.Name</MudChip> *@
<Chip Graphic="Graphic.Tag" Varient="ElementVarient.Outlined" Color="@GetColorVarient()" Tone="ToneVarient.None" Click="@OnClick">@Tag.Name</Chip>
@code { @code {
[Inject] [Inject]
@@ -33,6 +35,21 @@
return string.Join(" ", classNames); return string.Join(" ", classNames);
} }
private ColorVarient GetColorVarient()
{
// if (Tag.IsFavorite)
// {
// return ColorVarient.Mint;
// }
// if (Tag.IsBlacklisted)
// {
// return ColorVarient.Red;
// }
return ColorVarient.Primary;
}
private void OnClick() private void OnClick()
{ {
VoiceWorkFilterState state = new() VoiceWorkFilterState state = new()

View File

@@ -5,6 +5,7 @@
@using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Enums
@using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Filters
@using JSMR.UI.Blazor.Services @using JSMR.UI.Blazor.Services
@using AntDesign
<div class="search-toolbar"> <div class="search-toolbar">
<BitMenuButton TItem="BitMenuButtonItem" Text="Presets" Items="presets" Variant="BitVariant.Outline" OnClick="test" /> <BitMenuButton TItem="BitMenuButtonItem" Text="Presets" Items="presets" Variant="BitVariant.Outline" OnClick="test" />
@@ -59,9 +60,18 @@
Value="@Value.SaleStatus" Value="@Value.SaleStatus"
ValueChanged="@(value => Update(Value with { SaleStatus = value }))"> ValueChanged="@(value => Update(Value with { SaleStatus = value }))">
<PrefixTemplate> <PrefixTemplate>
<InputPrefix Graphic="Graphic.Cart" Tooltip="Keywords"></InputPrefix> <InputPrefix Graphic="Graphic.Cart" Tooltip="Sale Status"></InputPrefix>
</PrefixTemplate> </PrefixTemplate>
</BitDropdown> </BitDropdown>
@* <Select TItem="SelectOption<SaleStatus?>"
TItemValue="SaleStatus?"
Placeholder="All (Placeholder)"
ValueName="Value"
LabelName="Label"
DataSource="saleStatuses2"
Value="@Value.SaleStatus"
ValueChanged="@(value => Update(Value with { SaleStatus = value }))">
</Select> *@
</div> </div>
<div class="search-filter-control-span-1"> <div class="search-filter-control-span-1">
<BitDropdown Prefix="Circles" <BitDropdown Prefix="Circles"
@@ -219,6 +229,7 @@
List<BitDropdownItem<Locale>> locales = []; List<BitDropdownItem<Locale>> locales = [];
List<BitDropdownItem<SaleStatus?>> saleStatuses = []; List<BitDropdownItem<SaleStatus?>> saleStatuses = [];
List<SelectOption<SaleStatus?>> saleStatuses2 = [];
List<BitDropdownItem<CircleStatus?>> circleStatuses = []; List<BitDropdownItem<CircleStatus?>> circleStatuses = [];
List<BitDropdownItem<TagStatus?>> tagStatuses = []; List<BitDropdownItem<TagStatus?>> tagStatuses = [];
List<BitDropdownItem<CreatorStatus?>> creatorStatuses = []; List<BitDropdownItem<CreatorStatus?>> creatorStatuses = [];
@@ -242,6 +253,7 @@
{ {
locales = Lookups.GetLocales(); locales = Lookups.GetLocales();
saleStatuses = Lookups.GetSaleStatuses(); saleStatuses = Lookups.GetSaleStatuses();
saleStatuses2 = Lookups.GetSaleStatuses2();
circleStatuses = Lookups.GetCircleStatuses(); circleStatuses = Lookups.GetCircleStatuses();
tagStatuses = Lookups.GetTagStatuses(); tagStatuses = Lookups.GetTagStatuses();
creatorStatuses = Lookups.GetCreatorStatuses(); creatorStatuses = Lookups.GetCreatorStatuses();

View File

@@ -20,7 +20,8 @@ public enum ColorVarient
Teal, Teal,
Blue, Blue,
Orange, Orange,
Pink Pink,
Red
} }
public static class CssUtil public static class CssUtil

Some files were not shown because too many files have changed in this diff Show More