Compare commits
21 Commits
v0.0.1-rc1
...
ee809e374f
| Author | SHA1 | Date | |
|---|---|---|---|
| ee809e374f | |||
| ddd0738cb5 | |||
| 1cbadd3042 | |||
| a8dd1fa6aa | |||
| 62c2efab01 | |||
| 1e01edf1b7 | |||
| 1d40013837 | |||
| 61f2e64972 | |||
| 79bece9e1c | |||
| 83655f13e9 | |||
| 704a6fc433 | |||
| ca7ffa1730 | |||
| ae8d7d34d9 | |||
| 0bfbc17a43 | |||
| ab3524ea20 | |||
| 80ca1296e5 | |||
| 8348603b13 | |||
| 9f30ef446a | |||
| a85989a337 | |||
| 340c62d18b | |||
| c51775592e |
@@ -7,14 +7,18 @@ on:
|
|||||||
- 'JSMR.Application/**'
|
- 'JSMR.Application/**'
|
||||||
- 'JSMR.Domain/**'
|
- 'JSMR.Domain/**'
|
||||||
- 'JSMR.Infrastructure/**'
|
- 'JSMR.Infrastructure/**'
|
||||||
|
- 'JSMR.UI.Blazor/**'
|
||||||
- 'JSMR.Tests/**'
|
- 'JSMR.Tests/**'
|
||||||
|
- '.gitea/workflows/ci.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'JSMR.Api/**'
|
- 'JSMR.Api/**'
|
||||||
- 'JSMR.Application/**'
|
- 'JSMR.Application/**'
|
||||||
- 'JSMR.Domain/**'
|
- 'JSMR.Domain/**'
|
||||||
- 'JSMR.Infrastructure/**'
|
- 'JSMR.Infrastructure/**'
|
||||||
|
- 'JSMR.UI.Blazor/**'
|
||||||
- 'JSMR.Tests/**'
|
- 'JSMR.Tests/**'
|
||||||
|
- '.gitea/workflows/ci.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test:
|
build-test:
|
||||||
@@ -31,7 +35,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Docker sanity (ensures socket mount is working)
|
- name: Docker sanity (ensures socket mount is working)
|
||||||
run: docker version
|
run: docker version
|
||||||
@@ -44,7 +48,8 @@ jobs:
|
|||||||
- run: dotnet test --configuration Release --no-build --logger "trx;LogFileName=test-results.trx"
|
- run: dotnet test --configuration Release --no-build --logger "trx;LogFileName=test-results.trx"
|
||||||
|
|
||||||
publish-image:
|
publish-image:
|
||||||
if: ${{ false }} # disabled for now
|
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
||||||
|
needs: build-test
|
||||||
runs-on: [self-hosted, linux, x64, docker]
|
runs-on: [self-hosted, linux, x64, docker]
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/catthehacker/ubuntu:act-latest
|
image: ghcr.io/catthehacker/ubuntu:act-latest
|
||||||
@@ -52,19 +57,20 @@ jobs:
|
|||||||
--privileged
|
--privileged
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ secrets.REGISTRY_HOST }}
|
REGISTRY: ${{ secrets.REGISTRY_HOST }}
|
||||||
OWNER_REPO: ${{ github.repository }}
|
|
||||||
DOCKERFILE: JSMR.Api/Dockerfile
|
|
||||||
CONTEXT: .
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Normalize image name (lowercase)
|
- name: Set image variables
|
||||||
id: names
|
id: vars
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
IMAGE_LC="$(echo "${REGISTRY}/${OWNER_REPO}" | tr '[:upper:]' '[:lower:]')"
|
OWNER_LC="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
echo "image=${IMAGE_LC}" >> "$GITHUB_OUTPUT"
|
SHA_SHORT="$(echo "${{ github.sha }}" | cut -c1-7)"
|
||||||
|
echo "owner_lc=$OWNER_LC" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha_short=$SHA_SHORT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Docker login
|
- name: Docker login
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
| docker login "${{ env.REGISTRY }}" \
|
| docker login "${{ env.REGISTRY }}" \
|
||||||
@@ -74,11 +80,32 @@ jobs:
|
|||||||
- name: Enable BuildKit
|
- name: Enable BuildKit
|
||||||
run: echo "DOCKER_BUILDKIT=1" >> $GITHUB_ENV
|
run: echo "DOCKER_BUILDKIT=1" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build
|
- name: Build API image
|
||||||
run: docker build -f "$DOCKERFILE" -t "${{ steps.names.outputs.image }}:${{ github.sha }}" "$CONTEXT"
|
shell: bash
|
||||||
|
|
||||||
- name: Push
|
|
||||||
run: |
|
run: |
|
||||||
docker push "${{ steps.names.outputs.image }}:${{ github.sha }}"
|
docker build \
|
||||||
docker tag "${{ steps.names.outputs.image }}:${{ github.sha }}" "${{ steps.names.outputs.image }}:latest"
|
-f JSMR.Api/Dockerfile \
|
||||||
docker push "${{ steps.names.outputs.image }}:latest"
|
-t "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-api:${{ steps.vars.outputs.sha_short }}" \
|
||||||
|
-t "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-api:main" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push API image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker push "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-api:${{ steps.vars.outputs.sha_short }}"
|
||||||
|
docker push "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-api:main"
|
||||||
|
|
||||||
|
- name: Build Web image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-f JSMR.UI.Blazor/Dockerfile \
|
||||||
|
-t "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-web:${{ steps.vars.outputs.sha_short }}" \
|
||||||
|
-t "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-web:main" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Web image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker push "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-web:${{ steps.vars.outputs.sha_short }}"
|
||||||
|
docker push "${{ env.REGISTRY }}/${{ steps.vars.outputs.owner_lc }}/jsmr-web:main"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -361,3 +361,6 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# --- Build stage ---
|
# --- Build stage ---
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# copy solution + project files first (for caching)
|
# copy solution + project files first (for caching)
|
||||||
@@ -16,7 +16,7 @@ COPY . .
|
|||||||
RUN dotnet publish JSMR.Api/JSMR.Api.csproj -c Release -o /app/publish --no-restore
|
RUN dotnet publish JSMR.Api/JSMR.Api.csproj -c Release -o /app/publish --no-restore
|
||||||
|
|
||||||
# --- Runtime stage ---
|
# --- Runtime stage ---
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish ./
|
COPY --from=build /app/publish ./
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>35cebc06-af6a-44cf-aa71-ecdaf1edc82b</UserSecretsId>
|
<UserSecretsId>35cebc06-af6a-44cf-aa71-ecdaf1edc82b</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.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.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
@host = http://localhost:5226
|
@host = http://localhost:5226
|
||||||
@contentType = application/json
|
@contentType = application/json
|
||||||
|
|
||||||
|
### Login
|
||||||
|
POST {{host}}/auth/login
|
||||||
|
Content-Type: {{contentType}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"Username": "brister",
|
||||||
|
"Password": "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
POST {{host}}/auth/logout
|
||||||
|
Content-Type: {{contentType}}
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
### Get current user
|
||||||
|
GET {{host}}/api/me
|
||||||
|
Content-Type: {{contentType}}
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
### Search tags by name
|
### Search tags by name
|
||||||
POST {{host}}/api/tags/search
|
POST {{host}}/api/tags/search
|
||||||
Content-Type: {{contentType}}
|
Content-Type: {{contentType}}
|
||||||
|
|||||||
@@ -1,158 +1,26 @@
|
|||||||
using JSMR.Application.Circles.Queries.Search;
|
using JSMR.Api.Startup;
|
||||||
using JSMR.Application.Creators.Queries.Search;
|
|
||||||
using JSMR.Application.DI;
|
|
||||||
using JSMR.Application.Tags.Queries.Search;
|
|
||||||
using JSMR.Application.VoiceWorks.Queries.Search;
|
|
||||||
using JSMR.Infrastructure.Data;
|
|
||||||
using JSMR.Infrastructure.DI;
|
|
||||||
using Microsoft.AspNetCore.Http.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Serilog;
|
|
||||||
using Serilog.Events;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
ConfigurationManager configuration = builder.Configuration;
|
||||||
|
IWebHostEnvironment environment = builder.Environment;
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddMemoryCache() // TODO
|
.AddAppServices(configuration)
|
||||||
.AddApplication()
|
.AddAppJson()
|
||||||
.AddInfrastructure();
|
.AddAppOpenApi()
|
||||||
|
.AddAppAuthentication(environment)
|
||||||
|
.AddAppCors(configuration);
|
||||||
|
//.AddAppLogging(builder);
|
||||||
|
|
||||||
// DbContext (MySQL here; swap to Npgsql when you migrate)
|
builder.Host.UseAppSerilog();
|
||||||
var cs = builder.Configuration.GetConnectionString("AppDb")
|
|
||||||
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2");
|
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<AppDbContext>(opt =>
|
WebApplication app = builder.Build();
|
||||||
opt.UseMySql(cs, ServerVersion.AutoDetect(cs))
|
app.UseAppPipeline(app.Environment);
|
||||||
.EnableSensitiveDataLogging(false));
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
|
|
||||||
builder.Services.Configure<JsonOptions>(options =>
|
|
||||||
{
|
|
||||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
|
||||||
options.SerializerOptions.Converters.Add(
|
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); // or null for exact names
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serilog bootstrap (before Build)
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
|
||||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
||||||
.Enrich.WithProperty("Service", "JSMR.Api")
|
|
||||||
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
|
|
||||||
.CreateLogger();
|
|
||||||
builder.Host.UseSerilog();
|
|
||||||
|
|
||||||
builder.Services.AddCors(o =>
|
|
||||||
{
|
|
||||||
o.AddPolicy("ui-dev", p => p
|
|
||||||
.AllowAnyOrigin()
|
|
||||||
//.WithOrigins(
|
|
||||||
// "*",
|
|
||||||
// "https://localhost:5173", // vite-like
|
|
||||||
// "https://localhost:5001", // typical https dev
|
|
||||||
// "http://localhost:5000", // typical http dev
|
|
||||||
// "https://localhost:7112", // blazor wasm dev https (adjust to your port)
|
|
||||||
// "http://localhost:5153" // blazor wasm dev http (adjust to your port)
|
|
||||||
//)
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod());
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
app.UseCors("ui-dev");
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
await app.SeedDevelopmentAsync();
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.MapAppEndpoints();
|
||||||
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
// Request logging with latency, status, path
|
|
||||||
app.UseSerilogRequestLogging(opts =>
|
|
||||||
{
|
|
||||||
opts.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Correlation: ensure every log has traceId and return it to clients
|
|
||||||
app.Use(async (ctx, next) =>
|
|
||||||
{
|
|
||||||
// Use current Activity if present (W3C trace context), else fall back
|
|
||||||
var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;
|
|
||||||
using (Serilog.Context.LogContext.PushProperty("TraceId", traceId))
|
|
||||||
{
|
|
||||||
ctx.Response.Headers["x-trace-id"] = traceId;
|
|
||||||
await next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
|
||||||
|
|
||||||
// ---- Endpoints ----
|
|
||||||
|
|
||||||
// Circles Search
|
|
||||||
app.MapPost("/api/circles/search", async (
|
|
||||||
SearchCirclesRequest request,
|
|
||||||
SearchCirclesHandler handler,
|
|
||||||
CancellationToken cancallationToken) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
SearchCirclesResponse result = await handler.HandleAsync(request, cancallationToken);
|
|
||||||
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancallationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Voice Works Search
|
|
||||||
app.MapPost("/api/voiceworks/search", async (
|
|
||||||
SearchVoiceWorksRequest request,
|
|
||||||
SearchVoiceWorksHandler handler,
|
|
||||||
CancellationToken cancallationToken) =>
|
|
||||||
{
|
|
||||||
var result = await handler.HandleAsync(request, cancallationToken);
|
|
||||||
|
|
||||||
return Results.Ok(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tags Search
|
|
||||||
app.MapPost("/api/tags/search", async (
|
|
||||||
SearchTagsRequest request,
|
|
||||||
SearchTagsHandler handler,
|
|
||||||
CancellationToken cancallationToken) =>
|
|
||||||
{
|
|
||||||
var result = await handler.HandleAsync(request, cancallationToken);
|
|
||||||
|
|
||||||
return Results.Ok(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Creators Search
|
|
||||||
app.MapPost("/api/creators/search", async (
|
|
||||||
SearchCreatorsRequest request,
|
|
||||||
SearchCreatorsHandler handler,
|
|
||||||
CancellationToken cancallationToken) =>
|
|
||||||
{
|
|
||||||
var result = await handler.HandleAsync(request, cancallationToken);
|
|
||||||
|
|
||||||
return Results.Ok(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
30
JSMR.Api/Startup/DevSeeding.cs
Normal file
30
JSMR.Api/Startup/DevSeeding.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using JSMR.Domain.Entities;
|
||||||
|
using JSMR.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JSMR.Api.Startup;
|
||||||
|
|
||||||
|
public static class DevSeeding
|
||||||
|
{
|
||||||
|
public static async Task SeedDevelopmentAsync(this WebApplication app)
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var username = "brister";
|
||||||
|
|
||||||
|
if (!await db.Users.AnyAsync(u => u.Username == username))
|
||||||
|
{
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Username = username,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
|
||||||
|
Role = "Admin",
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Console.WriteLine("✅ Seeded development user: brister / password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
JSMR.Api/Startup/HostBuilderExtensions.cs
Normal file
31
JSMR.Api/Startup/HostBuilderExtensions.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace JSMR.Api.Startup;
|
||||||
|
|
||||||
|
public static class HostBuilderExtensions
|
||||||
|
{
|
||||||
|
public static IHostBuilder UseAppSerilog(this IHostBuilder host)
|
||||||
|
{
|
||||||
|
return host.UseSerilog((context, services, loggerConfiguration) =>
|
||||||
|
{
|
||||||
|
IConfiguration configuration = context.Configuration;
|
||||||
|
IHostEnvironment environment = context.HostingEnvironment;
|
||||||
|
|
||||||
|
loggerConfiguration
|
||||||
|
.ReadFrom.Configuration(configuration)
|
||||||
|
.ReadFrom.Services(services)
|
||||||
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||||
|
.Enrich.WithProperty("Service", "JSMR.Api")
|
||||||
|
.Enrich.WithProperty("Environment", environment.EnvironmentName);
|
||||||
|
|
||||||
|
// Conditionally add Seq if configured correctly
|
||||||
|
string? seqUrl = configuration["Seq:ServerUrl"];
|
||||||
|
|
||||||
|
if (Uri.TryCreate(seqUrl, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
loggerConfiguration.WriteTo.Seq(seqUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
130
JSMR.Api/Startup/ServiceCollectionExtensions.cs
Normal file
130
JSMR.Api/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using JSMR.Application.DI;
|
||||||
|
using JSMR.Infrastructure.Data;
|
||||||
|
using JSMR.Infrastructure.DI;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Http.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JSMR.Api.Startup;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfigurationManager configuration)
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddMemoryCache()
|
||||||
|
.AddApplication()
|
||||||
|
.AddInfrastructure();
|
||||||
|
|
||||||
|
string connectionString = configuration.GetConnectionString("AppDb")
|
||||||
|
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
|
||||||
|
|
||||||
|
services.AddDbContextFactory<AppDbContext>(opt =>
|
||||||
|
opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
|
||||||
|
.EnableSensitiveDataLogging(false));
|
||||||
|
|
||||||
|
services.AddControllers();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppJson(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.Configure<JsonOptions>(options =>
|
||||||
|
{
|
||||||
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
options.SerializerOptions.Converters.Add(
|
||||||
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppOpenApi(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddOpenApi();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.Cookie.Name = "vw_auth";
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
//options.Cookie.SameSite = SameSiteMode.None;
|
||||||
|
//options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
|
||||||
|
options.Cookie.SecurePolicy =
|
||||||
|
environment.IsDevelopment()
|
||||||
|
? CookieSecurePolicy.SameAsRequest
|
||||||
|
: CookieSecurePolicy.Always;
|
||||||
|
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnRedirectToLogin = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnRedirectToAccessDenied = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppCors(this IServiceCollection services, IConfigurationManager configuration)
|
||||||
|
{
|
||||||
|
string[] origins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||||
|
|
||||||
|
services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("ui", policyBuilder =>
|
||||||
|
{
|
||||||
|
if (origins.Length == 0)
|
||||||
|
{
|
||||||
|
// In container/prod you often don't need CORS at all (same-origin),
|
||||||
|
// but if it *is* needed and not configured, fail closed rather than crash.
|
||||||
|
// Do not call WithOrigins().
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
policyBuilder.WithOrigins(origins)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
//public static IServiceCollection AddAppLogging(this IServiceCollection services, IHostApplicationBuilder builder)
|
||||||
|
//{
|
||||||
|
// var config = builder.Configuration;
|
||||||
|
// var env = builder.Environment;
|
||||||
|
|
||||||
|
// Log.Logger = new LoggerConfiguration()
|
||||||
|
// .ReadFrom.Configuration(config)
|
||||||
|
// .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||||
|
// .Enrich.WithProperty("Service", "JSMR.Api")
|
||||||
|
// .Enrich.WithProperty("Environment", env.EnvironmentName)
|
||||||
|
// .CreateLogger();
|
||||||
|
|
||||||
|
// return services;
|
||||||
|
//}
|
||||||
|
}
|
||||||
151
JSMR.Api/Startup/WebApplicationExtensions.cs
Normal file
151
JSMR.Api/Startup/WebApplicationExtensions.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using JSMR.Application.Circles.Queries.Search;
|
||||||
|
using JSMR.Application.Creators.Queries.Search;
|
||||||
|
using JSMR.Application.Tags.Queries.Search;
|
||||||
|
using JSMR.Application.Users;
|
||||||
|
using JSMR.Application.VoiceWorks.Queries.Search;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Serilog;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace JSMR.Api.Startup;
|
||||||
|
|
||||||
|
public static class WebApplicationExtensions
|
||||||
|
{
|
||||||
|
public static WebApplication UseAppPipeline(this WebApplication app, IHostEnvironment env)
|
||||||
|
{
|
||||||
|
string[] origins = app.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||||
|
|
||||||
|
if (origins.Length > 0)
|
||||||
|
app.UseCors("ui");
|
||||||
|
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
if (!env.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.UseSerilogRequestLogging(opts =>
|
||||||
|
{
|
||||||
|
opts.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;
|
||||||
|
using (Serilog.Context.LogContext.PushProperty("TraceId", traceId))
|
||||||
|
{
|
||||||
|
ctx.Response.Headers["x-trace-id"] = traceId;
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapAppEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
|
app.MapSearchEndpoints();
|
||||||
|
app.MapAuthenticationEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MapSearchEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/circles/search", async (
|
||||||
|
SearchCirclesRequest request,
|
||||||
|
SearchCirclesHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await handler.HandleAsync(request, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/voiceworks/search", async (
|
||||||
|
SearchVoiceWorksRequest request,
|
||||||
|
SearchVoiceWorksHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await handler.HandleAsync(request, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/tags/search", async (
|
||||||
|
SearchTagsRequest request,
|
||||||
|
SearchTagsHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await handler.HandleAsync(request, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/creators/search", async (
|
||||||
|
SearchCreatorsRequest request,
|
||||||
|
SearchCreatorsHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await handler.HandleAsync(request, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MapAuthenticationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/auth/login", async (LoginRequest req, IUserRepository users, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var user = await users.FindByUsernameAsync(req.Username);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
if (!users.VerifyPassword(user, req.Password))
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new(ClaimTypes.Name, user.Username),
|
||||||
|
new(ClaimTypes.Role, user.Role ?? "User")
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
|
return Results.Ok(new { ok = true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/auth/logout", async (HttpContext http) =>
|
||||||
|
{
|
||||||
|
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Results.Ok(new { ok = true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/me", (ClaimsPrincipal user) =>
|
||||||
|
{
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
name = user.Identity?.Name,
|
||||||
|
id = user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||||
|
role = user.FindFirstValue(ClaimTypes.Role)
|
||||||
|
});
|
||||||
|
}).RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(string Username, string Password);
|
||||||
|
}
|
||||||
@@ -6,6 +6,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"https://localhost:5173", // vite-like
|
||||||
|
"https://localhost:5001", // typical https dev
|
||||||
|
"http://localhost:5000", // typical http dev
|
||||||
|
"https://localhost:7112", // blazor wasm dev https (adjust to your port)
|
||||||
|
"http://localhost:5153" // blazor wasm dev http (adjust to your port)
|
||||||
|
]
|
||||||
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -16,11 +25,10 @@
|
|||||||
},
|
},
|
||||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ],
|
"Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ],
|
||||||
"WriteTo": [
|
"WriteTo": [
|
||||||
{ "Name": "Console" },
|
{ "Name": "Console" }
|
||||||
{
|
|
||||||
"Name": "Seq",
|
|
||||||
"Args": { "serverUrl": "%SEQ_URL%" }
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Seq": {
|
||||||
|
"ServerUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using JSMR.Application.Circles.Queries.Search;
|
using JSMR.Application.Circles.Queries.Search;
|
||||||
using JSMR.Application.Creators.Queries.Search;
|
using JSMR.Application.Creators.Queries.Search;
|
||||||
|
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;
|
||||||
@@ -16,7 +17,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddScoped<SearchCirclesHandler>();
|
services.AddScoped<SearchCirclesHandler>();
|
||||||
|
|
||||||
services.AddScoped<SearchVoiceWorksHandler>();
|
services.AddScoped<SearchVoiceWorksHandler>();
|
||||||
//services.AddScoped<ScanVoiceWorksHandler>();
|
services.AddScoped<ScanVoiceWorksHandler>();
|
||||||
|
|
||||||
services.AddScoped<SearchTagsHandler>();
|
services.AddScoped<SearchTagsHandler>();
|
||||||
services.AddScoped<SetTagEnglishNameHandler>();
|
services.AddScoped<SetTagEnglishNameHandler>();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace JSMR.Application.Integrations.DLSite.Models;
|
|||||||
|
|
||||||
public class VoiceWorkDetails
|
public class VoiceWorkDetails
|
||||||
{
|
{
|
||||||
|
public string? Title { get; init; }
|
||||||
public VoiceWorkSeries? Series { get; init; }
|
public VoiceWorkSeries? Series { get; init; }
|
||||||
public VoiceWorkTranslation? Translation { get; init; }
|
public VoiceWorkTranslation? Translation { get; init; }
|
||||||
public AgeRating AgeRating { get; init; }
|
public AgeRating AgeRating { get; init; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ namespace JSMR.Application.Scanning.Contracts;
|
|||||||
|
|
||||||
public class DLSiteWork
|
public class DLSiteWork
|
||||||
{
|
{
|
||||||
public DLSiteWorkType Type { get; set; }
|
//public DLSiteWorkType Type { get; set; }
|
||||||
public DLSiteWorkCategory Category { 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? 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; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace JSMR.Application.Scanning.Contracts;
|
namespace JSMR.Application.Scanning.Contracts;
|
||||||
|
|
||||||
public enum DLSiteWorkType
|
//public enum DLSiteWorkType
|
||||||
{
|
//{
|
||||||
Released,
|
// Released,
|
||||||
Announced
|
// Announced
|
||||||
}
|
//}
|
||||||
@@ -36,7 +36,7 @@ public sealed record VoiceWorkIngest
|
|||||||
MakerId = work.MakerId,
|
MakerId = work.MakerId,
|
||||||
MakerName = work.Maker,
|
MakerName = work.Maker,
|
||||||
ProductId = work.ProductId,
|
ProductId = work.ProductId,
|
||||||
Title = work.ProductName,
|
Title = details?.Title ?? work.ProductName,
|
||||||
Description = work.Description ?? string.Empty,
|
Description = work.Description ?? string.Empty,
|
||||||
Tags = work.Tags,
|
Tags = work.Tags,
|
||||||
Creators = work.Creators,
|
Creators = work.Creators,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace JSMR.Application.Scanning.Contracts;
|
||||||
|
|
||||||
|
public record VoiceWorkScanResult(
|
||||||
|
DLSiteWork[] Works,
|
||||||
|
bool EndOfResults
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using JSMR.Application.Enums;
|
||||||
|
|
||||||
|
namespace JSMR.Application.Scanning.Ports;
|
||||||
|
public interface IVoiceWorkScannerRepository
|
||||||
|
{
|
||||||
|
public IVoiceWorksScanner? GetScanner(Locale locale);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using JSMR.Application.Scanning.Contracts;
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Domain.Enums;
|
||||||
|
|
||||||
namespace JSMR.Application.Scanning.Ports;
|
namespace JSMR.Application.Scanning.Ports;
|
||||||
|
|
||||||
@@ -9,7 +10,10 @@ public interface IVoiceWorkUpdater
|
|||||||
|
|
||||||
public class VoiceWorkUpsertResult
|
public class VoiceWorkUpsertResult
|
||||||
{
|
{
|
||||||
|
public string ProductId { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
public int? VoiceWorkId { get; set; }
|
public int? VoiceWorkId { get; set; }
|
||||||
|
public VoiceWorkStatus UpdateStatus { get; set; }
|
||||||
public ICollection<VoiceWorkUpsertIssue> Issues { get; } = [];
|
public ICollection<VoiceWorkUpsertIssue> Issues { get; } = [];
|
||||||
public VoiceWorkUpsertStatus Status { get; set; } = VoiceWorkUpsertStatus.Unchanged;
|
public VoiceWorkUpsertStatus Status { get; set; } = VoiceWorkUpsertStatus.Unchanged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using JSMR.Application.Enums;
|
||||||
|
|
||||||
|
namespace JSMR.Application.Scanning.Ports;
|
||||||
|
|
||||||
|
public interface IVoiceWorkUpdaterRepository
|
||||||
|
{
|
||||||
|
public IVoiceWorkUpdater? GetUpdater(Locale locale);
|
||||||
|
}
|
||||||
@@ -4,5 +4,5 @@ namespace JSMR.Application.Scanning.Ports;
|
|||||||
|
|
||||||
public interface IVoiceWorksScanner
|
public interface IVoiceWorksScanner
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<DLSiteWork>> ScanPageAsync(VoiceWorkScanOptions request, CancellationToken cancellationToken = default);
|
Task<VoiceWorkScanResult> ScanPageAsync(VoiceWorkScanOptions request, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
@@ -3,23 +3,26 @@ using JSMR.Application.Integrations.DLSite.Models;
|
|||||||
using JSMR.Application.Integrations.Ports;
|
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.DependencyInjection;
|
|
||||||
|
|
||||||
namespace JSMR.Application.Scanning;
|
namespace JSMR.Application.Scanning;
|
||||||
|
|
||||||
public sealed class ScanVoiceWorksHandler(
|
public sealed class ScanVoiceWorksHandler(
|
||||||
IServiceProvider serviceProvider,
|
IVoiceWorkScannerRepository scannerRepository,
|
||||||
|
IVoiceWorkUpdaterRepository updaterRepository,
|
||||||
IDLSiteClient dlsiteClient,
|
IDLSiteClient dlsiteClient,
|
||||||
ISpamCircleCache spamCircleCache,
|
ISpamCircleCache spamCircleCache,
|
||||||
IVoiceWorkSearchUpdater searchUpdater)
|
IVoiceWorkSearchUpdater searchUpdater)
|
||||||
{
|
{
|
||||||
public async Task<ScanVoiceWorksResponse> HandleAsync(ScanVoiceWorksRequest request, CancellationToken cancellationToken)
|
public async Task<ScanVoiceWorksResponse> HandleAsync(ScanVoiceWorksRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IVoiceWorksScanner? scanner = serviceProvider.GetKeyedService<IVoiceWorksScanner>(request.Locale);
|
IVoiceWorksScanner? scanner = scannerRepository.GetScanner(request.Locale);
|
||||||
IVoiceWorkUpdater? updater = serviceProvider.GetKeyedService<IVoiceWorkUpdater>(request.Locale);
|
IVoiceWorkUpdater? updater = updaterRepository.GetUpdater(request.Locale);
|
||||||
|
|
||||||
if (scanner is null || updater is null)
|
if (scanner is null)
|
||||||
return new();
|
throw new InvalidOperationException($"No scanner registered for locale {request.Locale}.");
|
||||||
|
|
||||||
|
if (updater is null)
|
||||||
|
throw new InvalidOperationException($"No updater registered for locale {request.Locale}.");
|
||||||
|
|
||||||
VoiceWorkScanOptions options = new(
|
VoiceWorkScanOptions options = new(
|
||||||
PageNumber: request.PageNumber,
|
PageNumber: request.PageNumber,
|
||||||
@@ -29,15 +32,20 @@ public sealed class ScanVoiceWorksHandler(
|
|||||||
ExcludeAIGeneratedWorks: true
|
ExcludeAIGeneratedWorks: true
|
||||||
);
|
);
|
||||||
|
|
||||||
IReadOnlyList<DLSiteWork> works = await scanner.ScanPageAsync(options, cancellationToken);
|
VoiceWorkScanResult scanResult = await scanner.ScanPageAsync(options, cancellationToken);
|
||||||
|
|
||||||
if (works.Count == 0)
|
if (scanResult.EndOfResults)
|
||||||
return new();
|
{
|
||||||
|
return new ScanVoiceWorksResponse(
|
||||||
|
Results: [],
|
||||||
|
EndOfResults: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
string[] productIds = [.. works.Where(x => !string.IsNullOrWhiteSpace(x.ProductId)).Select(x => x.ProductId!)];
|
string[] productIds = [.. scanResult.Works.Where(x => !string.IsNullOrWhiteSpace(x.ProductId)).Select(x => x.ProductId!)];
|
||||||
VoiceWorkDetailCollection voiceWorkDetails = await dlsiteClient.GetVoiceWorkDetailsAsync(productIds, cancellationToken);
|
VoiceWorkDetailCollection voiceWorkDetails = await dlsiteClient.GetVoiceWorkDetailsAsync(productIds, cancellationToken);
|
||||||
|
|
||||||
VoiceWorkIngest[] ingests = [.. works.Select(work =>
|
VoiceWorkIngest[] ingests = [.. scanResult.Works.Select(work =>
|
||||||
{
|
{
|
||||||
voiceWorkDetails.TryGetValue(work.ProductId!, out VoiceWorkDetails? value);
|
voiceWorkDetails.TryGetValue(work.ProductId!, out VoiceWorkDetails? value);
|
||||||
return VoiceWorkIngest.From(work, value);
|
return VoiceWorkIngest.From(work, value);
|
||||||
@@ -48,6 +56,9 @@ public sealed class ScanVoiceWorksHandler(
|
|||||||
|
|
||||||
await searchUpdater.UpdateAsync(voiceWorkIds, cancellationToken);
|
await searchUpdater.UpdateAsync(voiceWorkIds, cancellationToken);
|
||||||
|
|
||||||
return new();
|
return new ScanVoiceWorksResponse(
|
||||||
|
Results: upsertResults,
|
||||||
|
EndOfResults: false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
namespace JSMR.Application.Scanning;
|
namespace JSMR.Application.Scanning;
|
||||||
|
|
||||||
public sealed record ScanVoiceWorksRequest(int PageNumber, int PageSize, Locale Locale, string[] ExcludedMakerIds);
|
public sealed record ScanVoiceWorksRequest(int PageNumber, int PageSize, Locale Locale);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace JSMR.Application.Scanning;
|
using JSMR.Application.Scanning.Ports;
|
||||||
|
|
||||||
public sealed class ScanVoiceWorksResponse
|
namespace JSMR.Application.Scanning;
|
||||||
{
|
|
||||||
public int Inserted { get; init; }
|
public sealed record ScanVoiceWorksResponse(
|
||||||
public int Updated { get; init; }
|
VoiceWorkUpsertResult[] Results,
|
||||||
}
|
bool EndOfResults
|
||||||
|
);
|
||||||
9
JSMR.Application/Users/IUserRepository.cs
Normal file
9
JSMR.Application/Users/IUserRepository.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using JSMR.Domain.Entities;
|
||||||
|
|
||||||
|
namespace JSMR.Application.Users;
|
||||||
|
|
||||||
|
public interface IUserRepository
|
||||||
|
{
|
||||||
|
Task<User?> FindByUsernameAsync(string username);
|
||||||
|
bool VerifyPassword(User user, string password);
|
||||||
|
}
|
||||||
@@ -27,18 +27,31 @@ public record VoiceWorkSearchResult
|
|||||||
public byte Status { get; init; }
|
public byte Status { get; init; }
|
||||||
public byte SubtitleLanguage { get; init; }
|
public byte SubtitleLanguage { get; init; }
|
||||||
public bool? IsValid { get; init; }
|
public bool? IsValid { get; init; }
|
||||||
|
public required VoiceWorkCircleItem Circle { get; set; }
|
||||||
public VoiceWorkTagItem[] Tags { get; set; } = [];
|
public VoiceWorkTagItem[] Tags { get; set; } = [];
|
||||||
public VoiceWorkCreatorItem[] Creators { get; set; } = [];
|
public VoiceWorkCreatorItem[] Creators { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class VoiceWorkCircleItem
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string MakerId { get; init; }
|
||||||
|
public bool IsFavorite { get; init; }
|
||||||
|
public bool IsBlacklisted { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public class VoiceWorkTagItem
|
public class VoiceWorkTagItem
|
||||||
{
|
{
|
||||||
public int TagId { get; set; }
|
public int TagId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
public bool IsFavorite { get; set; }
|
||||||
|
public bool IsBlacklisted { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class VoiceWorkCreatorItem
|
public class VoiceWorkCreatorItem
|
||||||
{
|
{
|
||||||
public int CreatorId { get; set; }
|
public int CreatorId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
public bool IsFavorite { get; set; }
|
||||||
|
public bool IsBlacklisted { get; set; }
|
||||||
}
|
}
|
||||||
10
JSMR.Domain/Entities/User.cs
Normal file
10
JSMR.Domain/Entities/User.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace JSMR.Domain.Entities;
|
||||||
|
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ 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.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;
|
||||||
|
using JSMR.Application.Users;
|
||||||
using JSMR.Application.VoiceWorks.Ports;
|
using JSMR.Application.VoiceWorks.Ports;
|
||||||
using JSMR.Application.VoiceWorks.Queries.Search;
|
using JSMR.Application.VoiceWorks.Queries.Search;
|
||||||
using JSMR.Infrastructure.Caching;
|
using JSMR.Infrastructure.Caching;
|
||||||
@@ -18,11 +20,16 @@ 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.Tags;
|
using JSMR.Infrastructure.Data.Repositories.Tags;
|
||||||
|
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.DLSite;
|
||||||
using JSMR.Infrastructure.Scanning;
|
using JSMR.Infrastructure.Scanning;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Http.Resilience;
|
||||||
|
using Polly;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
namespace JSMR.Infrastructure.DI;
|
namespace JSMR.Infrastructure.DI;
|
||||||
|
|
||||||
@@ -40,9 +47,13 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddKeyedScoped<IVoiceWorksScanner, JapaneseVoiceWorksScanner>(Locale.Japanese);
|
services.AddKeyedScoped<IVoiceWorksScanner, JapaneseVoiceWorksScanner>(Locale.Japanese);
|
||||||
services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English);
|
services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English);
|
||||||
|
services.AddScoped<IVoiceWorkScannerRepository, VoiceWorkScannerRepository>();
|
||||||
|
|
||||||
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<IVoiceWorkSearchUpdater, VoiceWorkSearchUpdater>();
|
||||||
|
|
||||||
//services.AddKeyedScoped<ISupportedLanguage, JapaneseLanguage>(Locale.Japanese);
|
//services.AddKeyedScoped<ISupportedLanguage, JapaneseLanguage>(Locale.Japanese);
|
||||||
//services.AddKeyedScoped<ISupportedLanguage, EnglishLanguage>(Locale.English);
|
//services.AddKeyedScoped<ISupportedLanguage, EnglishLanguage>(Locale.English);
|
||||||
@@ -59,42 +70,51 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
services.AddSingleton<ICache, MemoryCacheAdapter>();
|
services.AddSingleton<ICache, MemoryCacheAdapter>();
|
||||||
services.AddSingleton<ISpamCircleCache, SpamCircleCache>();
|
services.AddSingleton<ISpamCircleCache, SpamCircleCache>();
|
||||||
|
|
||||||
services.AddHttpClient<IHttpService, HttpService>(client =>
|
|
||||||
{
|
|
||||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddScoped<IHttpService, HttpService>();
|
|
||||||
services.AddScoped<IHtmlLoader, HtmlLoader>();
|
|
||||||
|
|
||||||
services.AddSingleton<ILanguageIdentifier, LanguageIdentifier>();
|
services.AddSingleton<ILanguageIdentifier, LanguageIdentifier>();
|
||||||
|
|
||||||
services.AddSingleton<IClock, Clock>();
|
services.AddSingleton<IClock, Clock>();
|
||||||
services.AddSingleton<ITimeProvider, TokyoTimeProvider>();
|
services.AddSingleton<ITimeProvider, TokyoTimeProvider>();
|
||||||
|
|
||||||
|
services.AddHttpServices();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
//public static IServiceCollection AddDLSiteClient(this IServiceCollection services)
|
private static IServiceCollection AddHttpServices(this IServiceCollection services)
|
||||||
//{
|
{
|
||||||
// var retryPolicy = HttpPolicyExtensions
|
//services.AddHttpClient<IHttpService, HttpService>(client =>
|
||||||
// .HandleTransientHttpError()
|
//{
|
||||||
// .OrResult(msg => (int)msg.StatusCode == 429) // Too Many Requests
|
// client.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
|
||||||
// .WaitAndRetryAsync(new[]
|
//});
|
||||||
// {
|
|
||||||
// TimeSpan.FromMilliseconds(200),
|
|
||||||
// TimeSpan.FromMilliseconds(500),
|
|
||||||
// TimeSpan.FromSeconds(1.5)
|
|
||||||
// });
|
|
||||||
|
|
||||||
// services.AddHttpClient<IDLSiteClient, DLSiteClient>(c =>
|
//services.AddScoped<IHttpService, HttpService>();
|
||||||
// {
|
services.AddScoped<IHtmlLoader, HtmlLoader>();
|
||||||
// c.BaseAddress = new Uri("https://www.dlsite.com/");
|
|
||||||
// c.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0 (+contact@example.com)");
|
|
||||||
// c.Timeout = TimeSpan.FromSeconds(15);
|
|
||||||
// })
|
|
||||||
// .AddPolicyHandler(retryPolicy);
|
|
||||||
|
|
||||||
// return services;
|
// ONE registration for IHttpService as a typed client:
|
||||||
//}
|
services.AddHttpClient<IHttpService, HttpService>((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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register DLSiteClient as a normal scoped service
|
||||||
|
services.AddScoped<IDLSiteClient, DLSiteClient>();
|
||||||
|
|
||||||
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
public DbSet<VoiceWorkCreator> VoiceWorkCreators { get; set; }
|
public DbSet<VoiceWorkCreator> VoiceWorkCreators { get; set; }
|
||||||
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; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
30
JSMR.Infrastructure/Data/AppDbContextFactory.cs
Normal file
30
JSMR.Infrastructure/Data/AppDbContextFactory.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Data;
|
||||||
|
|
||||||
|
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
// adjust base path if needed (points to the worker for secrets/env)
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||||
|
.AddUserSecrets(typeof(AppDbContextFactory).Assembly, optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var conn = config.GetConnectionString("AppDb")
|
||||||
|
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseMySql(conn, ServerVersion.AutoDetect(conn))
|
||||||
|
.EnableSensitiveDataLogging(false)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ public sealed class VoiceWorkLocalizationConfiguration : IEntityTypeConfiguratio
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<VoiceWorkLocalization> builder)
|
public void Configure(EntityTypeBuilder<VoiceWorkLocalization> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("voice_work_localizations");
|
builder.ToTable("VoiceWorkLocalizations");
|
||||||
builder.HasKey(x => x.VoiceWorkLocalizationId);
|
builder.HasKey(x => x.VoiceWorkLocalizationId);
|
||||||
|
|
||||||
builder.Property(x => x.Language).IsRequired().HasMaxLength(10);
|
builder.Property(x => x.Language).IsRequired().HasMaxLength(10);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration<Voic
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<VoiceWorkSearch> builder)
|
public void Configure(EntityTypeBuilder<VoiceWorkSearch> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("VoiceWorkSearches2");
|
builder.ToTable("VoiceWorkSearches");
|
||||||
builder.HasKey(x => x.VoiceWorkId); // also the FK
|
builder.HasKey(x => x.VoiceWorkId); // also the FK
|
||||||
|
|
||||||
builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT
|
builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public sealed class VoiceWorkSupportedLanguageConfiguration : IEntityTypeConfigu
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<VoiceWorkSupportedLanguage> builder)
|
public void Configure(EntityTypeBuilder<VoiceWorkSupportedLanguage> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("voice_work_supported_languages");
|
builder.ToTable("VoiceWorkSupportedLanguages");
|
||||||
builder.HasKey(x => x.VoiceWorkSupportedLanguageId);
|
builder.HasKey(x => x.VoiceWorkSupportedLanguageId);
|
||||||
|
|
||||||
builder.Property(x => x.Language).IsRequired().HasMaxLength(10);
|
builder.Property(x => x.Language).IsRequired().HasMaxLength(10);
|
||||||
|
|||||||
@@ -29,69 +29,6 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
|
|||||||
return q;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
//protected override IQueryable<CircleSearchItem> GetBaseQuery()
|
|
||||||
//{
|
|
||||||
// // Project from Circles so we can use correlated subqueries per CircleId.
|
|
||||||
// var q =
|
|
||||||
// from c in context.Circles.AsNoTracking()
|
|
||||||
// select new CircleSearchItem
|
|
||||||
// {
|
|
||||||
// CircleId = c.CircleId,
|
|
||||||
// Name = c.Name,
|
|
||||||
// MakerId = c.MakerId,
|
|
||||||
// Favorite = c.Favorite,
|
|
||||||
// Blacklisted = c.Blacklisted,
|
|
||||||
// Spam = c.Spam,
|
|
||||||
|
|
||||||
// // Aggregates
|
|
||||||
// Downloads = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .Select(v => (int?)v.Downloads) // make nullable for Sum over empty set
|
|
||||||
// .Sum() ?? 0,
|
|
||||||
|
|
||||||
// Releases = context.VoiceWorks
|
|
||||||
// .Count(v => v.CircleId == c.CircleId && v.SalesDate != null),
|
|
||||||
|
|
||||||
// Pending = context.VoiceWorks
|
|
||||||
// .Count(v => v.CircleId == c.CircleId && v.ExpectedDate != null),
|
|
||||||
|
|
||||||
// FirstReleaseDate = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .Select(v => v.SalesDate)
|
|
||||||
// .Min(),
|
|
||||||
|
|
||||||
// LatestReleaseDate = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .Select(v => v.SalesDate)
|
|
||||||
// .Max(),
|
|
||||||
|
|
||||||
// // "Latest" by ProductId length, then value
|
|
||||||
// LatestProductId = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .OrderByDescending(v => v.ProductId.Length)
|
|
||||||
// .ThenByDescending(v => v.ProductId)
|
|
||||||
// .Select(v => v.ProductId)
|
|
||||||
// .FirstOrDefault(),
|
|
||||||
|
|
||||||
// // If you want these two in base query too:
|
|
||||||
// LatestVoiceWorkHasImage = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .OrderByDescending(v => v.ProductId.Length)
|
|
||||||
// .ThenByDescending(v => v.ProductId)
|
|
||||||
// .Select(v => (bool?)v.HasImage)
|
|
||||||
// .FirstOrDefault(),
|
|
||||||
|
|
||||||
// LatestVoiceWorkSalesDate = context.VoiceWorks
|
|
||||||
// .Where(v => v.CircleId == c.CircleId)
|
|
||||||
// .OrderByDescending(v => v.ProductId.Length)
|
|
||||||
// .ThenByDescending(v => v.ProductId)
|
|
||||||
// .Select(v => v.SalesDate)
|
|
||||||
// .FirstOrDefault()
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return q;
|
|
||||||
//}
|
|
||||||
|
|
||||||
protected override IQueryable<CircleQuery> ApplyFilters(IQueryable<CircleQuery> query, CircleSearchCriteria criteria)
|
protected override IQueryable<CircleQuery> ApplyFilters(IQueryable<CircleQuery> query, CircleSearchCriteria criteria)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(criteria.Name))
|
if (!string.IsNullOrWhiteSpace(criteria.Name))
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using JSMR.Application.Users;
|
||||||
|
using JSMR.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Data.Repositories.Users;
|
||||||
|
|
||||||
|
public class UserRepository(AppDbContext db) : IUserRepository
|
||||||
|
{
|
||||||
|
public async Task<User?> FindByUsernameAsync(string username)
|
||||||
|
{
|
||||||
|
return await db.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyPassword(User user, string password)
|
||||||
|
{
|
||||||
|
// Using BCrypt (recommended)
|
||||||
|
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.SupportedLanguages.Length > 0)
|
if (criteria.SupportedLanguages.Length > 0)
|
||||||
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
|
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.AsEnumerable().Contains((Language)x.VoiceWork.SubtitleLanguage));
|
||||||
|
|
||||||
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
|
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
|
||||||
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
|
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
|
||||||
@@ -85,13 +85,13 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
filteredQuery = filteredQuery.Where(x => x.VoiceWork.SalesDate <= criteria.ReleaseDateEnd.Value.ToDateTime(TimeOnly.MinValue));
|
filteredQuery = filteredQuery.Where(x => x.VoiceWork.SalesDate <= criteria.ReleaseDateEnd.Value.ToDateTime(TimeOnly.MinValue));
|
||||||
|
|
||||||
if (criteria.AgeRatings.Length > 0)
|
if (criteria.AgeRatings.Length > 0)
|
||||||
filteredQuery = filteredQuery.Where(x => criteria.AgeRatings.Contains((AgeRating)x.VoiceWork.Rating));
|
filteredQuery = filteredQuery.Where(x => criteria.AgeRatings.AsEnumerable().Contains((AgeRating)x.VoiceWork.Rating));
|
||||||
|
|
||||||
//if (criteria.SupportedLanguages.Length > 0)
|
//if (criteria.SupportedLanguages.Length > 0)
|
||||||
// filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
|
// filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
|
||||||
|
|
||||||
if (criteria.AIGenerationOptions.Length > 0)
|
if (criteria.AIGenerationOptions.Length > 0)
|
||||||
filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.Contains((AIGeneration)x.VoiceWork.AIGeneration));
|
filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.AsEnumerable().Contains((AIGeneration)x.VoiceWork.AIGeneration));
|
||||||
|
|
||||||
if (criteria.ShowFavoriteVoiceWorks)
|
if (criteria.ShowFavoriteVoiceWorks)
|
||||||
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Favorite);
|
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Favorite);
|
||||||
@@ -376,6 +376,13 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
HasImage = voiceWork.HasImage,
|
HasImage = voiceWork.HasImage,
|
||||||
Maker = circle.Name,
|
Maker = circle.Name,
|
||||||
MakerId = circle.MakerId,
|
MakerId = circle.MakerId,
|
||||||
|
Circle = new()
|
||||||
|
{
|
||||||
|
Name = circle.Name,
|
||||||
|
MakerId = circle.MakerId,
|
||||||
|
IsFavorite = circle.Favorite,
|
||||||
|
IsBlacklisted = circle.Blacklisted
|
||||||
|
},
|
||||||
ExpectedDate = voiceWork.ExpectedDate,
|
ExpectedDate = voiceWork.ExpectedDate,
|
||||||
SalesDate = voiceWork.SalesDate,
|
SalesDate = voiceWork.SalesDate,
|
||||||
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
|
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
|
||||||
@@ -413,6 +420,13 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
HasImage = voiceWork.HasImage,
|
HasImage = voiceWork.HasImage,
|
||||||
Maker = circle.Name,
|
Maker = circle.Name,
|
||||||
MakerId = circle.MakerId,
|
MakerId = circle.MakerId,
|
||||||
|
Circle = new()
|
||||||
|
{
|
||||||
|
Name = circle.Name,
|
||||||
|
MakerId = circle.MakerId,
|
||||||
|
IsFavorite = circle.Favorite,
|
||||||
|
IsBlacklisted = circle.Blacklisted
|
||||||
|
},
|
||||||
ExpectedDate = voiceWork.ExpectedDate,
|
ExpectedDate = voiceWork.ExpectedDate,
|
||||||
SalesDate = voiceWork.SalesDate,
|
SalesDate = voiceWork.SalesDate,
|
||||||
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
|
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
|
||||||
@@ -457,14 +471,14 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
from englishTag in et.DefaultIfEmpty()
|
from englishTag in et.DefaultIfEmpty()
|
||||||
where voiceWorkIds.Contains(voiceWorkTag.VoiceWorkId)
|
where voiceWorkIds.Contains(voiceWorkTag.VoiceWorkId)
|
||||||
orderby voiceWorkTag.VoiceWorkId, voiceWorkTag.Position
|
orderby voiceWorkTag.VoiceWorkId, voiceWorkTag.Position
|
||||||
select new { voiceWorkTag.VoiceWorkId, voiceWorkTag.TagId, tag.Name, EnglishName = englishTag.Name }
|
select new { voiceWorkTag.VoiceWorkId, voiceWorkTag.TagId, tag.Name, EnglishName = englishTag.Name, IsFavorite = tag.Favorite, IsBlacklisted = tag.Blacklisted }
|
||||||
).ToListAsync(cancellationToken);
|
).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return tagRows
|
return tagRows
|
||||||
.GroupBy(r => r.VoiceWorkId)
|
.GroupBy(r => r.VoiceWorkId)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => g.Select(r => new VoiceWorkTagItem { TagId = r.TagId, Name = r.EnglishName ?? r.Name }).ToArray()
|
g => g.Select(r => new VoiceWorkTagItem { TagId = r.TagId, Name = r.EnglishName ?? r.Name, IsFavorite = r.IsFavorite, IsBlacklisted = r.IsBlacklisted }).ToArray()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,14 +489,14 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
|
|||||||
join creator in context.Creators.AsNoTracking() on voiceWorkCreator.CreatorId equals creator.CreatorId
|
join creator in context.Creators.AsNoTracking() on voiceWorkCreator.CreatorId equals creator.CreatorId
|
||||||
where voiceWorkIds.Contains(voiceWorkCreator.VoiceWorkId)
|
where voiceWorkIds.Contains(voiceWorkCreator.VoiceWorkId)
|
||||||
orderby voiceWorkCreator.VoiceWorkId, voiceWorkCreator.Position
|
orderby voiceWorkCreator.VoiceWorkId, voiceWorkCreator.Position
|
||||||
select new { voiceWorkCreator.VoiceWorkId, creator.CreatorId, creator.Name }
|
select new { voiceWorkCreator.VoiceWorkId, creator.CreatorId, creator.Name, creator.Favorite, creator.Blacklisted }
|
||||||
).ToListAsync(cancellationToken);
|
).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return creatorRows
|
return creatorRows
|
||||||
.GroupBy(r => r.VoiceWorkId)
|
.GroupBy(r => r.VoiceWorkId)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => g.Select(r => new VoiceWorkCreatorItem { CreatorId = r.CreatorId, Name = r.Name }).ToArray()
|
g => g.Select(r => new VoiceWorkCreatorItem { CreatorId = r.CreatorId, Name = r.Name, IsFavorite = r.Favorite, IsBlacklisted = r.Blacklisted }).ToArray()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,12 @@ public abstract class ApiClient(IHttpService http, ILogger logger, JsonSerialize
|
|||||||
{
|
{
|
||||||
protected async Task<T> GetJsonAsync<T>(string url, CancellationToken cancellationToken = default)
|
protected async Task<T> GetJsonAsync<T>(string url, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
string response = await http.GetStringAsync(url, cancellationToken);
|
HttpStringResponse response = await http.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<T>(response, json)
|
if (response.Content is null)
|
||||||
|
throw new Exception("No content to deserialize");
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<T>(response.Content, json)
|
||||||
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}.");
|
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
JSMR.Infrastructure/Http/HtmlLoadResult.cs
Normal file
12
JSMR.Infrastructure/Http/HtmlLoadResult.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using HtmlAgilityPack;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Http;
|
||||||
|
|
||||||
|
public sealed class HtmlLoadResult
|
||||||
|
{
|
||||||
|
public required HttpStatusCode StatusCode { get; init; }
|
||||||
|
public HtmlDocument? Document { get; init; }
|
||||||
|
|
||||||
|
public bool IsSuccessStatusCode => (int)StatusCode is >= 200 and <= 299;
|
||||||
|
}
|
||||||
@@ -4,13 +4,26 @@ namespace JSMR.Infrastructure.Http;
|
|||||||
|
|
||||||
public class HtmlLoader(IHttpService httpService) : IHtmlLoader
|
public class HtmlLoader(IHttpService httpService) : IHtmlLoader
|
||||||
{
|
{
|
||||||
public async Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
|
public async Task<HtmlLoadResult> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string html = await httpService.GetStringAsync(url, cancellationToken);
|
HttpStringResponse response = await httpService.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new HtmlLoadResult
|
||||||
|
{
|
||||||
|
StatusCode = response.StatusCode,
|
||||||
|
Document = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
HtmlDocument document = new();
|
HtmlDocument document = new();
|
||||||
document.LoadHtml(html);
|
document.LoadHtml(response.Content ?? string.Empty);
|
||||||
|
|
||||||
return document;
|
return new HtmlLoadResult
|
||||||
|
{
|
||||||
|
StatusCode = response.StatusCode,
|
||||||
|
Document = document
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
public class HttpService(HttpClient httpClient) : IHttpService
|
public class HttpService(HttpClient httpClient) : IHttpService
|
||||||
{
|
{
|
||||||
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
|
public Task<HttpStringResponse> GetAsync(string url, CancellationToken cancellationToken)
|
||||||
=> GetStringAsync(url, new Dictionary<string, string>(), cancellationToken);
|
=> GetAsync(url, new Dictionary<string, string>(), cancellationToken);
|
||||||
|
|
||||||
public async Task<string> GetStringAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken)
|
public async Task<HttpStringResponse> GetAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using HttpRequestMessage request = new(HttpMethod.Get, url);
|
using HttpRequestMessage request = new(HttpMethod.Get, url);
|
||||||
|
|
||||||
@@ -14,11 +14,18 @@ public class HttpService(HttpClient httpClient) : IHttpService
|
|||||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
|
request.Headers.UserAgent.ParseAdd("JSMR/1.0");
|
||||||
|
|
||||||
using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken);
|
using HttpResponseMessage response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
string? content = response.Content is null
|
||||||
|
? null
|
||||||
|
: await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new HttpStringResponse
|
||||||
|
{
|
||||||
|
StatusCode = response.StatusCode,
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
11
JSMR.Infrastructure/Http/HttpStringResponse.cs
Normal file
11
JSMR.Infrastructure/Http/HttpStringResponse.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Http;
|
||||||
|
|
||||||
|
public sealed class HttpStringResponse
|
||||||
|
{
|
||||||
|
public required HttpStatusCode StatusCode { get; init; }
|
||||||
|
public string? Content { get; init; }
|
||||||
|
|
||||||
|
public bool IsSuccessStatusCode => (int)StatusCode is >= 200 and <= 299;
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using HtmlAgilityPack;
|
namespace JSMR.Infrastructure.Http;
|
||||||
|
|
||||||
namespace JSMR.Infrastructure.Http;
|
|
||||||
|
|
||||||
public interface IHtmlLoader
|
public interface IHtmlLoader
|
||||||
{
|
{
|
||||||
Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken);
|
Task<HtmlLoadResult> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
public interface IHttpService
|
public interface IHttpService
|
||||||
{
|
{
|
||||||
Task<string> GetStringAsync(string url, CancellationToken cancellationToken);
|
Task<HttpStringResponse> GetAsync(string url, CancellationToken cancellationToken);
|
||||||
Task<string> GetStringAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken);
|
Task<HttpStringResponse> GetAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,10 @@ public class EnglishVoiceWorkUpdater(AppDbContext dbContext, ILanguageIdentifier
|
|||||||
Results: productIds.ToDictionary(
|
Results: productIds.ToDictionary(
|
||||||
productId => productId,
|
productId => productId,
|
||||||
productId => new VoiceWorkUpsertResult()
|
productId => new VoiceWorkUpsertResult()
|
||||||
|
{
|
||||||
|
ProductId = productId,
|
||||||
|
Title = ingests.First(x => x.ProductId == productId).Title
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
|
|||||||
public async Task UpdateAsync(int[] voiceWorkIds, CancellationToken cancellationToken)
|
public async Task UpdateAsync(int[] voiceWorkIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
List<VoiceWork> batch = await dbContext.VoiceWorks
|
List<VoiceWork> batch = await dbContext.VoiceWorks
|
||||||
|
.Where(vw => voiceWorkIds.Contains(vw.VoiceWorkId))
|
||||||
|
.AsSplitQuery()
|
||||||
.Include(vw => vw.Circle)
|
.Include(vw => vw.Circle)
|
||||||
.Include(vw => vw.Tags)
|
.Include(vw => vw.Tags)
|
||||||
.ThenInclude(vwt => vwt.Tag)
|
.ThenInclude(vwt => vwt.Tag)
|
||||||
.Include(vw => vw.Creators)
|
.Include(vw => vw.Creators)
|
||||||
.ThenInclude(vwc => vwc.Creator)
|
.ThenInclude(vwc => vwc.Creator)
|
||||||
.Include(vw => vw.EnglishVoiceWorks)
|
.Include(vw => vw.EnglishVoiceWorks)
|
||||||
.Where(vw => voiceWorkIds.Contains(vw.VoiceWorkId))
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
foreach (var voiceWork in batch)
|
foreach (var voiceWork in batch)
|
||||||
|
|||||||
@@ -28,10 +28,25 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.Status = Upsert(ingest, upsertContext);
|
result.Status = Upsert(ingest, upsertContext);
|
||||||
|
|
||||||
|
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
|
||||||
|
result.UpdateStatus = (VoiceWorkStatus)voiceWork.Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (VoiceWorkIngest ingest in ingests)
|
||||||
|
{
|
||||||
|
VoiceWorkUpsertResult result = upsertContext.Results[ingest.ProductId];
|
||||||
|
|
||||||
|
if (result.Status is VoiceWorkUpsertStatus.Skipped)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
|
||||||
|
|
||||||
|
result.VoiceWorkId = voiceWork.VoiceWorkId;
|
||||||
|
}
|
||||||
|
|
||||||
return [.. upsertContext.Results.Select(x => x.Value)];
|
return [.. upsertContext.Results.Select(x => x.Value)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +69,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
.ToDictionaryAsync(c => c.MakerId, cancellationToken),
|
.ToDictionaryAsync(c => c.MakerId, cancellationToken),
|
||||||
VoiceWorks: await dbContext.VoiceWorks
|
VoiceWorks: await dbContext.VoiceWorks
|
||||||
.Where(v => productIds.Contains(v.ProductId))
|
.Where(v => productIds.Contains(v.ProductId))
|
||||||
|
.AsSplitQuery()
|
||||||
.Include(v => v.Creators)
|
.Include(v => v.Creators)
|
||||||
.Include(v => v.Tags)
|
.Include(v => v.Tags)
|
||||||
.Include(v => v.Localizations)
|
.Include(v => v.Localizations)
|
||||||
@@ -71,6 +87,10 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
Results: productIds.ToDictionary(
|
Results: productIds.ToDictionary(
|
||||||
productId => productId,
|
productId => productId,
|
||||||
productId => new VoiceWorkUpsertResult()
|
productId => new VoiceWorkUpsertResult()
|
||||||
|
{
|
||||||
|
ProductId = productId,
|
||||||
|
Title = ingests.First(x => x.ProductId == productId).Title
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,7 +144,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
|
|
||||||
if (voiceWork.SalesDate is not null && ingest.SalesDate is null)
|
if (voiceWork.SalesDate is not null && ingest.SalesDate is null)
|
||||||
{
|
{
|
||||||
string message = $"Voice work has a sales date of {voiceWork.SalesDate.Value.ToShortDateString()}, but ingest does not";
|
string message = $"Voice work has a sales date of {voiceWork.SalesDate.Value:d}, but ingest does not";
|
||||||
result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error));
|
result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +210,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
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;
|
||||||
|
voiceWork.SubtitleLanguage = GetSubtitleLanguage(ingest);
|
||||||
voiceWork.AIGeneration = (byte)ingest.AI;
|
voiceWork.AIGeneration = (byte)ingest.AI;
|
||||||
voiceWork.IsValid = true;
|
voiceWork.IsValid = true;
|
||||||
voiceWork.LastScannedDate = ComputeLastScannedDate(voiceWork.LastScannedDate, state, upsertContext);
|
voiceWork.LastScannedDate = ComputeLastScannedDate(voiceWork.LastScannedDate, state, upsertContext);
|
||||||
@@ -204,7 +225,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
voiceWork.SalesDate = null;
|
voiceWork.SalesDate = null;
|
||||||
voiceWork.ExpectedDate = ingest.ExpectedDate?.ToDateTime(new TimeOnly(0, 0));
|
voiceWork.ExpectedDate = ingest.ExpectedDate?.ToDateTime(new TimeOnly(0, 0)) ?? voiceWork.ExpectedDate;
|
||||||
voiceWork.PlannedReleaseDate = ingest.RegistrationDate > upsertContext.CurrentScanAnchor ? ingest.RegistrationDate : null;
|
voiceWork.PlannedReleaseDate = ingest.RegistrationDate > upsertContext.CurrentScanAnchor ? ingest.RegistrationDate : null;
|
||||||
voiceWork.Status = state.IsNewUpcoming ? (byte)VoiceWorkStatus.NewAndUpcoming : (byte)VoiceWorkStatus.Upcoming;
|
voiceWork.Status = state.IsNewUpcoming ? (byte)VoiceWorkStatus.NewAndUpcoming : (byte)VoiceWorkStatus.Upcoming;
|
||||||
}
|
}
|
||||||
@@ -266,6 +287,36 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider
|
|||||||
return state.WentOnSale ? current : existing ?? current;
|
return state.WentOnSale ? current : existing ?? current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte GetSubtitleLanguage(VoiceWorkIngest ingest)
|
||||||
|
{
|
||||||
|
Language[] orderedLanguages =
|
||||||
|
[
|
||||||
|
Language.English,
|
||||||
|
Language.ChineseSimplified,
|
||||||
|
Language.ChineseTraditional,
|
||||||
|
Language.Korean
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (Language language in orderedLanguages)
|
||||||
|
{
|
||||||
|
if (ingest.SupportedLanguages.Any(x => x.Language == language))
|
||||||
|
{
|
||||||
|
switch (language)
|
||||||
|
{
|
||||||
|
case Language.English:
|
||||||
|
return 1;
|
||||||
|
case Language.ChineseSimplified:
|
||||||
|
case Language.ChineseTraditional:
|
||||||
|
return 2;
|
||||||
|
case Language.Korean:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private void UpsertTags(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
private void UpsertTags(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
||||||
{
|
{
|
||||||
foreach (string tagName in ingest.Tags)
|
foreach (string tagName in ingest.Tags)
|
||||||
|
|||||||
13
JSMR.Infrastructure/Ingestion/VoiceWorkUpdaterRepository.cs
Normal file
13
JSMR.Infrastructure/Ingestion/VoiceWorkUpdaterRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using JSMR.Application.Enums;
|
||||||
|
using JSMR.Application.Scanning.Ports;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Ingestion;
|
||||||
|
|
||||||
|
public class VoiceWorkUpdaterRepository(IServiceProvider serviceProvider) : IVoiceWorkUpdaterRepository
|
||||||
|
{
|
||||||
|
public IVoiceWorkUpdater? GetUpdater(Locale locale)
|
||||||
|
{
|
||||||
|
return serviceProvider.GetKeyedService<IVoiceWorkUpdater>(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace JSMR.Infrastructure.Integrations.DLSite;
|
namespace JSMR.Infrastructure.Integrations.DLSite;
|
||||||
|
|
||||||
public class DLSiteClient(IHttpService http, ILogger logger) : ApiClient(http, logger), IDLSiteClient
|
public class DLSiteClient(IHttpService 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public static class DLSiteToDomainMapper
|
|||||||
|
|
||||||
return new VoiceWorkDetails
|
return new VoiceWorkDetails
|
||||||
{
|
{
|
||||||
|
Title = productInfo.WorkName,
|
||||||
Series = MapSeries(productInfo),
|
Series = MapSeries(productInfo),
|
||||||
Translation = MapTranslation(productInfo, optionsSet),
|
Translation = MapTranslation(productInfo, optionsSet),
|
||||||
WishlistCount = productInfo.WishlistCount,
|
WishlistCount = productInfo.WishlistCount,
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ public class ProductInfo
|
|||||||
[JsonPropertyName("title_name")]
|
[JsonPropertyName("title_name")]
|
||||||
public string? TitleName { get; set; }
|
public string? TitleName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title_name_masked")]
|
||||||
|
public string? TitleNameMasked { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("title_volumn")]
|
[JsonPropertyName("title_volumn")]
|
||||||
public int? TitleVolumeNumber { get; set; }
|
public int? TitleVolumeNumber { get; set; }
|
||||||
|
|
||||||
@@ -166,6 +169,9 @@ public class ProductInfo
|
|||||||
[JsonPropertyName("work_name")]
|
[JsonPropertyName("work_name")]
|
||||||
public string? WorkName { get; set; }
|
public string? WorkName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("work_name_masked")]
|
||||||
|
public string? WorkNameMasked { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("work_image")]
|
[JsonPropertyName("work_image")]
|
||||||
public string? WorkImage { get; set; }
|
public string? WorkImage { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,31 @@ namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
|
|||||||
public sealed class DictionaryConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>> where TKey : notnull
|
public sealed class DictionaryConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>> where TKey : notnull
|
||||||
{
|
{
|
||||||
public override Dictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
public override Dictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.StartArray)
|
||||||
|
{
|
||||||
|
if (!reader.Read())
|
||||||
|
throw new JsonException("Unexpected end while reading array.");
|
||||||
|
|
||||||
|
if (reader.TokenType != JsonTokenType.EndArray)
|
||||||
|
throw new JsonException("Non-empty JSON array cannot be converted to Dictionary.");
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.StartObject)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new JsonException($"Unexpected token {reader.TokenType} when reading Dictionary.");
|
||||||
|
}
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, Dictionary<TKey, TValue> value, JsonSerializerOptions options)
|
public override void Write(Utf8JsonWriter writer, Dictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||||
=> JsonSerializer.Serialize(writer, value, options);
|
{
|
||||||
|
JsonSerializer.Serialize(writer, (IDictionary<TKey, TValue>)value, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -15,11 +15,16 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.1.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="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.4.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" />
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Scanning.Extensions;
|
||||||
|
|
||||||
|
public static class DLSiteWorkExtensions
|
||||||
|
{
|
||||||
|
public static void InferAndUpdateExpectedDates(this DLSiteWork[] works)
|
||||||
|
{
|
||||||
|
// Precompute nearest known effective date on the left and right for each index.
|
||||||
|
var left = new DateOnly?[works.Length];
|
||||||
|
var right = new DateOnly?[works.Length];
|
||||||
|
|
||||||
|
DateOnly? last = null;
|
||||||
|
for (int i = 0; i < works.Length; i++)
|
||||||
|
{
|
||||||
|
var effective = GetEffectiveDate(works[i]);
|
||||||
|
if (effective.HasValue)
|
||||||
|
last = effective;
|
||||||
|
|
||||||
|
left[i] = last;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateOnly? next = null;
|
||||||
|
for (int i = works.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var effective = GetEffectiveDate(works[i]);
|
||||||
|
if (effective.HasValue)
|
||||||
|
next = effective;
|
||||||
|
|
||||||
|
right[i] = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill only when BOTH sides exist and match.
|
||||||
|
for (int i = 0; i < works.Length; i++)
|
||||||
|
{
|
||||||
|
DLSiteWork work = works[i];
|
||||||
|
|
||||||
|
if (work.SalesDate.HasValue || work.ExpectedDate.HasValue)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
DateOnly? previous = (i > 0) ? left[i - 1] : null;
|
||||||
|
DateOnly? nxt = (i < works.Length - 1) ? right[i + 1] : null;
|
||||||
|
|
||||||
|
if (previous.HasValue && nxt.HasValue && previous.Value == nxt.Value)
|
||||||
|
work.ExpectedDate = previous.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? GetEffectiveDate(DLSiteWork work)
|
||||||
|
{
|
||||||
|
if (work.ExpectedDate.HasValue)
|
||||||
|
return work.ExpectedDate.Value;
|
||||||
|
|
||||||
|
if (!work.SalesDate.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Bucket sales day to Early/Middle/Late => 1/11/21
|
||||||
|
var d = work.SalesDate.Value;
|
||||||
|
int day = d.Day >= 21 ? 21 : d.Day >= 11 ? 11 : 1;
|
||||||
|
return new DateOnly(d.Year, d.Month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,37 +2,26 @@
|
|||||||
|
|
||||||
namespace JSMR.Infrastructure.Scanning.Models;
|
namespace JSMR.Infrastructure.Scanning.Models;
|
||||||
|
|
||||||
public class DLSiteHtmlDocument
|
public class DLSiteHtmlDocument(HtmlDocument document)
|
||||||
{
|
{
|
||||||
private readonly HtmlNodeCollection _workColumns;
|
private readonly HtmlNodeCollection _workColumns = document.DocumentNode.SelectNodes("//dl[@class='work_1col']");
|
||||||
private readonly HtmlNodeCollection _workColumnRights;
|
private readonly HtmlNodeCollection _workColumnRights = document.DocumentNode.SelectNodes("//td[contains(@class, 'work_1col_right')]");
|
||||||
private readonly HtmlNodeCollection _workThumbs;
|
private readonly HtmlNodeCollection _workThumbs = document.DocumentNode.SelectNodes("//div[@class='work_thumb']");
|
||||||
|
public HtmlNode PageTotalNode { get; } = document.DocumentNode.SelectNodes("//div[@class='page_total']/strong")[0];
|
||||||
|
|
||||||
public HtmlNode PageTotalNode { get; }
|
public DLSiteHtmlNode[] GetDLSiteNodes()
|
||||||
|
|
||||||
public DLSiteHtmlDocument(HtmlDocument document)
|
|
||||||
{
|
{
|
||||||
_workColumns = document.DocumentNode.SelectNodes("//dl[@class='work_1col']");
|
List<DLSiteHtmlNode> nodes = [];
|
||||||
//_workColumnRights = document.DocumentNode.SelectNodes("//td[@class='work_1col_right']");
|
|
||||||
_workColumnRights = document.DocumentNode.SelectNodes("//td[contains(@class, 'work_1col_right')]");
|
|
||||||
_workThumbs = document.DocumentNode.SelectNodes("//div[@class='work_thumb']");
|
|
||||||
|
|
||||||
PageTotalNode = document.DocumentNode.SelectNodes("//div[@class='page_total']/strong")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DLSiteHtmlNode> GetDLSiteNodes()
|
|
||||||
{
|
|
||||||
var nodes = new List<DLSiteHtmlNode>();
|
|
||||||
|
|
||||||
if (_workColumns.Count != _workColumnRights.Count || _workColumns.Count != _workThumbs.Count)
|
if (_workColumns.Count != _workColumnRights.Count || _workColumns.Count != _workThumbs.Count)
|
||||||
throw new Exception("Work column node counts do not match!");
|
throw new Exception("Work column node counts do not match!");
|
||||||
|
|
||||||
for (int i = 0; i < _workColumns.Count; i++)
|
for (int i = 0; i < _workColumns.Count; i++)
|
||||||
{
|
{
|
||||||
var node = new DLSiteHtmlNode(_workColumns[i], _workColumnRights[i], _workThumbs[i]);
|
DLSiteHtmlNode node = new(_workColumns[i], _workColumnRights[i], _workThumbs[i]);
|
||||||
nodes.Add(node);
|
nodes.Add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return [.. nodes];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,8 @@ public class DLSiteHtmlNode
|
|||||||
public HtmlNode? SalesDateNode { get; private set; }
|
public HtmlNode? SalesDateNode { get; private set; }
|
||||||
public HtmlNode DownloadsNode { get; private set; }
|
public HtmlNode DownloadsNode { get; private set; }
|
||||||
public HtmlNode? StarRatingNode { get; private set; }
|
public HtmlNode? StarRatingNode { get; private set; }
|
||||||
public HtmlNode ImageNode { get; private set; }
|
public HtmlNode? ImageNode { get; private set; }
|
||||||
|
public HtmlNode? ThumbWithNgFilterBlockNode { get; private set; }
|
||||||
public HtmlNode[] GenreNodes { get; private set; }
|
public HtmlNode[] GenreNodes { get; private set; }
|
||||||
public HtmlNode[] SearchTagNodes { get; private set; }
|
public HtmlNode[] SearchTagNodes { get; private set; }
|
||||||
public HtmlNode[] CreatorNodes { get; private set; }
|
public HtmlNode[] CreatorNodes { get; private set; }
|
||||||
@@ -55,7 +56,8 @@ public class DLSiteHtmlNode
|
|||||||
|
|
||||||
//InitializeSalesAndDownloadsNodes();
|
//InitializeSalesAndDownloadsNodes();
|
||||||
StarRatingNode = GetStarRatingNode();
|
StarRatingNode = GetStarRatingNode();
|
||||||
ImageNode = GetImageNode();
|
ImageNode = TryGetImageNode();
|
||||||
|
ThumbWithNgFilterBlockNode = ThumbNode.SelectSingleNode(".//thumb-with-ng-filter-block");
|
||||||
}
|
}
|
||||||
|
|
||||||
private HtmlNode[] GetGenreNodes()
|
private HtmlNode[] GetGenreNodes()
|
||||||
@@ -165,10 +167,13 @@ public class DLSiteHtmlNode
|
|||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
|
|
||||||
private HtmlNode GetImageNode()
|
private HtmlNode? TryGetImageNode()
|
||||||
{
|
{
|
||||||
HtmlNode linkNode = ThumbNode.SelectNodes(".//a")[0];
|
HtmlNode? linkNode = ThumbNode.SelectSingleNode(".//a");
|
||||||
|
|
||||||
return linkNode.SelectNodes(".//img")[0];
|
if (linkNode is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return linkNode.SelectSingleNode(".//img");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
|
|
||||||
namespace JSMR.Infrastructure.Scanning;
|
namespace JSMR.Infrastructure.Scanning;
|
||||||
@@ -45,4 +46,26 @@ public static class ScannerUtilities
|
|||||||
|
|
||||||
return imageSource;
|
return imageSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string[] ParseJavaScriptArray(string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = NormalizeJavaScriptArray(value);
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<string[]>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [.. value
|
||||||
|
.Trim('[', ']')
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(x => x.Trim().Trim('\'', '"'))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeJavaScriptArray(string input)
|
||||||
|
{
|
||||||
|
return input.Trim().Replace('\'', '"');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
13
JSMR.Infrastructure/Scanning/VoiceWorkScannerRepository.cs
Normal file
13
JSMR.Infrastructure/Scanning/VoiceWorkScannerRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using JSMR.Application.Enums;
|
||||||
|
using JSMR.Application.Scanning.Ports;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Scanning;
|
||||||
|
|
||||||
|
public class VoiceWorkScannerRepository(IServiceProvider serviceProvider) : IVoiceWorkScannerRepository
|
||||||
|
{
|
||||||
|
public IVoiceWorksScanner? GetScanner(Locale locale)
|
||||||
|
{
|
||||||
|
return serviceProvider.GetKeyedService<IVoiceWorksScanner>(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ using JSMR.Application.Scanning.Ports;
|
|||||||
using JSMR.Domain.Enums;
|
using JSMR.Domain.Enums;
|
||||||
using JSMR.Domain.ValueObjects;
|
using JSMR.Domain.ValueObjects;
|
||||||
using JSMR.Infrastructure.Http;
|
using JSMR.Infrastructure.Http;
|
||||||
|
using JSMR.Infrastructure.Scanning.Extensions;
|
||||||
using JSMR.Infrastructure.Scanning.Models;
|
using JSMR.Infrastructure.Scanning.Models;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace JSMR.Infrastructure.Scanning;
|
namespace JSMR.Infrastructure.Scanning;
|
||||||
@@ -19,21 +21,46 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
|
|||||||
protected abstract DateOnly? GetEstimatedReleaseDate(string expectedDate);
|
protected abstract DateOnly? GetEstimatedReleaseDate(string expectedDate);
|
||||||
protected abstract DateOnly? GetSalesDate(string salesDate);
|
protected abstract DateOnly? GetSalesDate(string salesDate);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DLSiteWork>> ScanPageAsync(VoiceWorkScanOptions options, CancellationToken cancellationToken = default)
|
public async Task<VoiceWorkScanResult> ScanPageAsync(VoiceWorkScanOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
|
||||||
DLSiteHtmlDocument document = await GetDLSiteHtmlCollectionAsync(options, cancellationToken);
|
|
||||||
List<DLSiteHtmlNode> nodes = document.GetDLSiteNodes();
|
|
||||||
|
|
||||||
return GetDLSiteWorks(nodes, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<DLSiteHtmlDocument> GetDLSiteHtmlCollectionAsync(VoiceWorkScanOptions options, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
string url = GetUrl(options);
|
string url = GetUrl(options);
|
||||||
|
HtmlLoadResult result = await htmlLoader.GetHtmlDocumentAsync(url, cancellationToken);
|
||||||
|
|
||||||
HtmlDocument document = await htmlLoader.GetHtmlDocumentAsync(url, cancellationToken);
|
// Expected boundary: past the last search page
|
||||||
|
if (result.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return new VoiceWorkScanResult(
|
||||||
|
Works: [],
|
||||||
|
EndOfResults: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new DLSiteHtmlDocument(document);
|
// Unexpected non-success response
|
||||||
|
if (!result.IsSuccessStatusCode || result.Document is null)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Unexpected response status code {(int)result.StatusCode} ({result.StatusCode}) while scanning {url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
DLSiteHtmlDocument document = new(result.Document);
|
||||||
|
DLSiteHtmlNode[] nodes = document.GetDLSiteNodes();
|
||||||
|
|
||||||
|
// Defensive fallback in case DLsite changes from 404 to 200 with empty page
|
||||||
|
if (nodes.Length == 0)
|
||||||
|
{
|
||||||
|
return new VoiceWorkScanResult(
|
||||||
|
Works: [],
|
||||||
|
EndOfResults: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DLSiteWork[] works = GetDLSiteWorks(nodes, options);
|
||||||
|
works.InferAndUpdateExpectedDates();
|
||||||
|
|
||||||
|
return new VoiceWorkScanResult(
|
||||||
|
Works: works,
|
||||||
|
EndOfResults: false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected string GetUrl(VoiceWorkScanOptions options)
|
protected string GetUrl(VoiceWorkScanOptions options)
|
||||||
@@ -52,7 +79,7 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
|
|||||||
return filterBuilder.BuildSearchQuery(options.PageNumber, options.PageSize);
|
return filterBuilder.BuildSearchQuery(options.PageNumber, options.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DLSiteWork> GetDLSiteWorks(List<DLSiteHtmlNode> nodes, VoiceWorkScanOptions options)
|
private DLSiteWork[] GetDLSiteWorks(DLSiteHtmlNode[] nodes, VoiceWorkScanOptions options)
|
||||||
{
|
{
|
||||||
var works = new List<DLSiteWork>();
|
var works = new List<DLSiteWork>();
|
||||||
|
|
||||||
@@ -66,15 +93,14 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
|
|||||||
works.Add(work);
|
works.Add(work);
|
||||||
}
|
}
|
||||||
|
|
||||||
return works;
|
return [.. works];
|
||||||
}
|
}
|
||||||
|
|
||||||
private DLSiteWork GetDLSiteWork(DLSiteHtmlNode node)
|
private DLSiteWork GetDLSiteWork(DLSiteHtmlNode node)
|
||||||
{
|
{
|
||||||
string productUrl = node.ProductLinkNode.Attributes["href"].Value;
|
string productUrl = node.ProductLinkNode.Attributes["href"].Value;
|
||||||
string makerUrl = node.MakerLinkNode.Attributes["href"].Value;
|
string makerUrl = node.MakerLinkNode.Attributes["href"].Value;
|
||||||
string imageSource = ScannerUtilities.GetImageSource(node.ImageNode);
|
(string imageSource, string imageUrl) = TryGetImageSourceAndUrl(node);
|
||||||
string imageUrl = imageSource.Replace("_sam.jpg", "_main.jpg").Replace("_sam.gif", "_main.gif");
|
|
||||||
ScannedRating? rating = GetScannedRating(node.StarRatingNode);
|
ScannedRating? rating = GetScannedRating(node.StarRatingNode);
|
||||||
|
|
||||||
DLSiteWork work = new()
|
DLSiteWork work = new()
|
||||||
@@ -89,7 +115,7 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
|
|||||||
Creators = ScannerUtilities.GetStringListFromNodes(node.CreatorNodes),
|
Creators = ScannerUtilities.GetStringListFromNodes(node.CreatorNodes),
|
||||||
SmallImageUrl = imageSource,
|
SmallImageUrl = imageSource,
|
||||||
ImageUrl = imageUrl,
|
ImageUrl = imageUrl,
|
||||||
Type = imageUrl.Contains("ana/doujin") ? DLSiteWorkType.Announced : DLSiteWorkType.Released,
|
//Type = imageUrl.Contains("ana/doujin") ? DLSiteWorkType.Announced : DLSiteWorkType.Released,
|
||||||
StarRating = rating?.Score,
|
StarRating = rating?.Score,
|
||||||
Votes = rating?.Votes,
|
Votes = rating?.Votes,
|
||||||
AgeRating = GetAgeRating(node.GenreNodes)
|
AgeRating = GetAgeRating(node.GenreNodes)
|
||||||
@@ -113,6 +139,36 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
|
|||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (string, string) TryGetImageSourceAndUrl(DLSiteHtmlNode node)
|
||||||
|
{
|
||||||
|
if (node.ThumbWithNgFilterBlockNode is not null)
|
||||||
|
{
|
||||||
|
string candidates = node.ThumbWithNgFilterBlockNode.GetAttributeValue(":thumb-candidates", string.Empty);
|
||||||
|
string[] imageUrls = ScannerUtilities.ParseJavaScriptArray(candidates);
|
||||||
|
|
||||||
|
if (imageUrls.Length == 0)
|
||||||
|
{
|
||||||
|
throw new Exception("No thumb candidartes found");
|
||||||
|
}
|
||||||
|
|
||||||
|
string imageSource = imageUrls[0];
|
||||||
|
string imageUrl = imageSource.Replace("_sam.jpg", "_main.jpg").Replace("_sam.gif", "_main.gif");
|
||||||
|
|
||||||
|
return (imageSource, imageUrl);
|
||||||
|
}
|
||||||
|
else if (node.ImageNode is not null)
|
||||||
|
{
|
||||||
|
string imageSource = ScannerUtilities.GetImageSource(node.ImageNode);
|
||||||
|
string imageUrl = imageSource.Replace("_sam.jpg", "_main.jpg").Replace("_sam.gif", "_main.gif");
|
||||||
|
|
||||||
|
return (imageSource, imageUrl);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Exception("Unable to find image source and/or url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static AgeRating GetAgeRating(HtmlNode[] genreNodes)
|
private static AgeRating GetAgeRating(HtmlNode[] genreNodes)
|
||||||
{
|
{
|
||||||
List<string> genres = ScannerUtilities.GetStringListFromNodes(genreNodes);
|
List<string> genres = ScannerUtilities.GetStringListFromNodes(genreNodes);
|
||||||
|
|||||||
20
JSMR.Tests/Extensions/HttpServiceTestExtensions.cs
Normal file
20
JSMR.Tests/Extensions/HttpServiceTestExtensions.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using JSMR.Infrastructure.Http;
|
||||||
|
using NSubstitute;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Extensions;
|
||||||
|
|
||||||
|
internal static class HttpServiceTestExtensions
|
||||||
|
{
|
||||||
|
public static void ReturnsContent(this IHttpService httpService, string content, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
HttpStringResponse response = new()
|
||||||
|
{
|
||||||
|
StatusCode = statusCode,
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
|
||||||
|
httpService.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
JSMR.Tests/Extensions/ScannerTestExtensions.cs
Normal file
17
JSMR.Tests/Extensions/ScannerTestExtensions.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Application.Scanning.Ports;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Extensions;
|
||||||
|
|
||||||
|
internal static class ScannerTestExtensions
|
||||||
|
{
|
||||||
|
public static async Task<IReadOnlyList<DLSiteWork>> ScanWorksAsync(this IVoiceWorksScanner scanner, VoiceWorkScanOptions options)
|
||||||
|
{
|
||||||
|
VoiceWorkScanResult result = await scanner.ScanPageAsync(options, CancellationToken.None);
|
||||||
|
|
||||||
|
result.EndOfResults.ShouldBeFalse();
|
||||||
|
|
||||||
|
return result.Works;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ public sealed class MariaDbContainerFixture : IAsyncLifetime
|
|||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
_container = new MariaDbBuilder()
|
_container = new MariaDbBuilder($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
||||||
.WithImage($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
//.WithImage($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
||||||
.WithEnvironment("MARIADB_ROOT_PASSWORD", "rootpw")
|
.WithEnvironment("MARIADB_ROOT_PASSWORD", "rootpw")
|
||||||
.WithUsername("root")
|
.WithUsername("root")
|
||||||
.WithPassword("rootpw")
|
.WithPassword("rootpw")
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ public class MariaDbFixture : IAsyncLifetime
|
|||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
MariaDbContainer = new MariaDbBuilder()
|
MariaDbContainer = new MariaDbBuilder($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
||||||
.WithImage($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
//.WithImage($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
await MariaDbContainer.StartAsync();
|
await MariaDbContainer.StartAsync();
|
||||||
|
|||||||
32
JSMR.Tests/Ingestion/IngestTestFactory.cs
Normal file
32
JSMR.Tests/Ingestion/IngestTestFactory.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Domain.Enums;
|
||||||
|
using JSMR.Domain.ValueObjects;
|
||||||
|
using JSMR.Tests.Ingestion.Search;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Ingestion;
|
||||||
|
|
||||||
|
internal class IngestTestFactory
|
||||||
|
{
|
||||||
|
public static VoiceWorkIngest Create(SearchRelatedParameters searchRelatedParameters)
|
||||||
|
{
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
MakerId = searchRelatedParameters.MakerId,
|
||||||
|
MakerName = searchRelatedParameters.MakerName,
|
||||||
|
ProductId = searchRelatedParameters.ProductId,
|
||||||
|
Title = searchRelatedParameters.Title,
|
||||||
|
Description = searchRelatedParameters.Description,
|
||||||
|
Tags = searchRelatedParameters.Tags,
|
||||||
|
Creators = searchRelatedParameters.Creators,
|
||||||
|
WishlistCount = 100,
|
||||||
|
Downloads = 0,
|
||||||
|
HasTrial = false,
|
||||||
|
HasDLPlay = false,
|
||||||
|
AgeRating = AgeRating.AllAges,
|
||||||
|
HasImage = false,
|
||||||
|
SupportedLanguages = [SupportedLanguage.Japanese],
|
||||||
|
SalesDate = null,
|
||||||
|
ExpectedDate = new DateOnly(2025, 1, 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using JSMR.Application.Scanning.Contracts;
|
using JSMR.Application.Enums;
|
||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
using JSMR.Application.Scanning.Ports;
|
using JSMR.Application.Scanning.Ports;
|
||||||
|
using JSMR.Domain.Entities;
|
||||||
using JSMR.Infrastructure.Common.Time;
|
using JSMR.Infrastructure.Common.Time;
|
||||||
using JSMR.Infrastructure.Data;
|
using JSMR.Infrastructure.Data;
|
||||||
using JSMR.Infrastructure.Ingestion;
|
using JSMR.Infrastructure.Ingestion;
|
||||||
@@ -25,6 +27,13 @@ public abstract class JapaneseIngestionTestsBase(MariaDbContainerFixture contain
|
|||||||
return await updater.UpsertAsync(ingests, TestContext.Current.CancellationToken);
|
return await updater.UpsertAsync(ingests, TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static async Task UpdateSearchAsync(AppDbContext dbContext, int[] voiceWorkIds)
|
||||||
|
{
|
||||||
|
VoiceWorkSearchUpdater updater = new(dbContext);
|
||||||
|
|
||||||
|
await updater.UpdateAsync(voiceWorkIds, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
protected static DateTime TokyoLocalToUtc(int year, int month, int day, int hour, int minute, int second)
|
protected static DateTime TokyoLocalToUtc(int year, int month, int day, int hour, int minute, int second)
|
||||||
{
|
{
|
||||||
var tokyo = TimeZoneInfo.FindSystemTimeZoneById(
|
var tokyo = TimeZoneInfo.FindSystemTimeZoneById(
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 Update_Upcoming_With_No_Expected_Date_Tests(MariaDbContainerFixture container) : JapaneseIngestionTestsBase(container)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_Upcoming_With_No_Expected_Date()
|
||||||
|
{
|
||||||
|
VoiceWorkIngest ingest = new()
|
||||||
|
{
|
||||||
|
MakerId = "RG00001",
|
||||||
|
MakerName = "Some Maker",
|
||||||
|
ProductId = "RJ1000001",
|
||||||
|
Title = "Some Upcoming Work",
|
||||||
|
Description = "Something is coming.",
|
||||||
|
Tags = [],
|
||||||
|
Creators = [],
|
||||||
|
WishlistCount = 250,
|
||||||
|
Downloads = 0,
|
||||||
|
HasTrial = false,
|
||||||
|
HasDLPlay = false,
|
||||||
|
StarRating = null,
|
||||||
|
Votes = null,
|
||||||
|
AgeRating = AgeRating.R15,
|
||||||
|
HasImage = true,
|
||||||
|
SupportedLanguages = [SupportedLanguage.Japanese],
|
||||||
|
SalesDate = null,
|
||||||
|
ExpectedDate = new DateOnly(2025, 1, 21),
|
||||||
|
RegistrationDate = null
|
||||||
|
};
|
||||||
|
|
||||||
|
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, new DateTime(2025, 1, 21));
|
||||||
|
|
||||||
|
VoiceWorkIngest updatedIngest = ingest with
|
||||||
|
{
|
||||||
|
ExpectedDate = null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should be exactly the same
|
||||||
|
await UpsertAndVerify(dbContext, TokyoLocalToUtc(2025, 01, 05, 10, 0, 0), ingest, new DateTime(2025, 1, 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpsertAndVerify(AppDbContext dbContext, DateTime dateTime, VoiceWorkIngest ingest, DateTime? expectedDate)
|
||||||
|
{
|
||||||
|
await UpsertAsync(dbContext, dateTime, [ingest]);
|
||||||
|
|
||||||
|
VoiceWork? voiceWork = await dbContext.VoiceWorks.SingleAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken);
|
||||||
|
voiceWork.ShouldNotBeNull();
|
||||||
|
voiceWork.ExpectedDate.ShouldBe(expectedDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
JSMR.Tests/Ingestion/Search/Insert_Into_Search_Tests.cs
Normal file
69
JSMR.Tests/Ingestion/Search/Insert_Into_Search_Tests.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Domain.Entities;
|
||||||
|
using JSMR.Infrastructure.Data;
|
||||||
|
using JSMR.Tests.Fixtures;
|
||||||
|
using JSMR.Tests.Ingestion.Japanese;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Ingestion.Search;
|
||||||
|
|
||||||
|
internal record SearchRelatedParameters(
|
||||||
|
string ProductId,
|
||||||
|
string MakerId,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string MakerName,
|
||||||
|
string[] Tags,
|
||||||
|
string[] Creators
|
||||||
|
);
|
||||||
|
|
||||||
|
public class Insert_Into_Search_Tests(MariaDbContainerFixture container) : JapaneseIngestionTestsBase(container)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Insert_Into_Search_And_Update()
|
||||||
|
{
|
||||||
|
await using AppDbContext dbContext = await GetAppDbContextAsync();
|
||||||
|
|
||||||
|
// PART 1 - Insert
|
||||||
|
SearchRelatedParameters parameters = new(
|
||||||
|
ProductId: "RJ1",
|
||||||
|
MakerId: "RG1",
|
||||||
|
Title: "Title",
|
||||||
|
Description: "Description",
|
||||||
|
MakerName: "Maker",
|
||||||
|
Tags: ["Tag 1", "Tag 2"],
|
||||||
|
Creators: ["Creator 1"]
|
||||||
|
);
|
||||||
|
|
||||||
|
VoiceWorkIngest ingest = IngestTestFactory.Create(parameters);
|
||||||
|
|
||||||
|
await UpsertAndVerify(dbContext, ingest, "RJ1 RG1 Title Description Maker Tag 1 Tag 2 Creator 1");
|
||||||
|
|
||||||
|
// PART 2 - Update
|
||||||
|
SearchRelatedParameters updateParameters = parameters with
|
||||||
|
{
|
||||||
|
Title = "Updated Title",
|
||||||
|
Tags = ["Tag 1", "Tag 2", "Tag 3"]
|
||||||
|
};
|
||||||
|
|
||||||
|
VoiceWorkIngest updatedIngest = IngestTestFactory.Create(updateParameters);
|
||||||
|
|
||||||
|
await UpsertAndVerify(dbContext, updatedIngest, "RJ1 RG1 Updated Title Description Maker Tag 1 Tag 2 Tag 3 Creator 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpsertAndVerify(AppDbContext dbContext, VoiceWorkIngest ingest, string searchText)
|
||||||
|
{
|
||||||
|
await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 15, 00, 00, 00), [ingest]);
|
||||||
|
|
||||||
|
VoiceWork? voiceWork = await dbContext.VoiceWorks.SingleAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken);
|
||||||
|
voiceWork.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await UpdateSearchAsync(dbContext, [voiceWork.VoiceWorkId]);
|
||||||
|
|
||||||
|
VoiceWorkSearch voiceWorkSearch = await dbContext.VoiceWorkSearches.SingleAsync(v => v.VoiceWorkId == voiceWork.VoiceWorkId, TestContext.Current.CancellationToken);
|
||||||
|
voiceWorkSearch.ShouldNotBeNull();
|
||||||
|
|
||||||
|
voiceWorkSearch.SearchText.ShouldBe(searchText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,7 @@
|
|||||||
using JSMR.Application.Common;
|
using JSMR.Tests.Fixtures;
|
||||||
using JSMR.Application.Scanning.Contracts;
|
|
||||||
using JSMR.Application.Scanning.Ports;
|
|
||||||
using JSMR.Domain.Entities;
|
|
||||||
using JSMR.Infrastructure.Common.Time;
|
|
||||||
using JSMR.Infrastructure.Data;
|
|
||||||
using JSMR.Infrastructure.Ingestion;
|
|
||||||
using JSMR.Tests.Fixtures;
|
|
||||||
using JSMR.Tests.Ingestion.Japanese;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NSubstitute;
|
|
||||||
using Shouldly;
|
|
||||||
|
|
||||||
namespace JSMR.Tests.Ingestion;
|
namespace JSMR.Tests.Ingestion;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public class VoiceWorkIngestionTests(MariaDbContainerFixture container) : IngestionTestsBase(container)
|
public class VoiceWorkIngestionTests(MariaDbContainerFixture container) : IngestionTestsBase(container)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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.Tests.Utilities;
|
using JSMR.Tests.Utilities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -25,18 +26,17 @@ public class DLSiteClientTests
|
|||||||
string productInfoJson = await ReadJsonResourceAsync("Product-Info.json");
|
string productInfoJson = await ReadJsonResourceAsync("Product-Info.json");
|
||||||
|
|
||||||
IHttpService httpService = Substitute.For<IHttpService>();
|
IHttpService httpService = Substitute.For<IHttpService>();
|
||||||
|
httpService.ReturnsContent(productInfoJson);
|
||||||
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
|
|
||||||
.Returns(Task.FromResult(productInfoJson));
|
|
||||||
|
|
||||||
var logger = Substitute.For<ILogger<DLSiteClient>>();
|
var logger = Substitute.For<ILogger<DLSiteClient>>();
|
||||||
var client = new DLSiteClient(httpService, logger);
|
var client = new DLSiteClient(httpService, logger);
|
||||||
|
|
||||||
var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163"], CancellationToken.None);
|
var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163", "RJ01536422"], CancellationToken.None);
|
||||||
|
|
||||||
result.Count.ShouldBe(1);
|
result.Count.ShouldBe(2);
|
||||||
|
|
||||||
result.ShouldContainKey("RJ01230163");
|
result.ShouldContainKey("RJ01230163");
|
||||||
|
result["RJ01230163"].Title.ShouldBe("[Azur Lane ASMR] Commander Pampering Team! Golden Hind's Tentacular Treatment");
|
||||||
result["RJ01230163"].HasTrial.ShouldBeTrue();
|
result["RJ01230163"].HasTrial.ShouldBeTrue();
|
||||||
result["RJ01230163"].HasDLPlay.ShouldBeTrue();
|
result["RJ01230163"].HasDLPlay.ShouldBeTrue();
|
||||||
result["RJ01230163"].HasReviews.ShouldBeTrue();
|
result["RJ01230163"].HasReviews.ShouldBeTrue();
|
||||||
@@ -44,6 +44,9 @@ public class DLSiteClientTests
|
|||||||
result["RJ01230163"].SupportedLanguages.Select(x => x.Language).ShouldContain(Language.English);
|
result["RJ01230163"].SupportedLanguages.Select(x => x.Language).ShouldContain(Language.English);
|
||||||
result["RJ01230163"].DownloadCount.ShouldBe(659);
|
result["RJ01230163"].DownloadCount.ShouldBe(659);
|
||||||
result["RJ01230163"].WishlistCount.ShouldBe(380);
|
result["RJ01230163"].WishlistCount.ShouldBe(380);
|
||||||
|
|
||||||
|
// Testing this one for empty array of prices
|
||||||
|
result.ShouldContainKey("RJ01536422");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -55,6 +58,8 @@ public class DLSiteClientTests
|
|||||||
"RG0001",
|
"RG0001",
|
||||||
new ProductInfo()
|
new ProductInfo()
|
||||||
{
|
{
|
||||||
|
WorkName = "Product 1",
|
||||||
|
WorkNameMasked = "Product 1 (Masked)",
|
||||||
WishlistCount = 250,
|
WishlistCount = 250,
|
||||||
DownloadCount = 100,
|
DownloadCount = 100,
|
||||||
Options = ["TRI", "DLP", "JPN"],
|
Options = ["TRI", "DLP", "JPN"],
|
||||||
@@ -66,6 +71,8 @@ public class DLSiteClientTests
|
|||||||
"RG0002",
|
"RG0002",
|
||||||
new ProductInfo()
|
new ProductInfo()
|
||||||
{
|
{
|
||||||
|
WorkName = "Product 2",
|
||||||
|
WorkNameMasked = "Product 2 (Masked)",
|
||||||
WishlistCount = 100,
|
WishlistCount = 100,
|
||||||
DownloadCount = 50,
|
DownloadCount = 50,
|
||||||
Options = ["TRI", "ENG", "DOT", "VET"],
|
Options = ["TRI", "ENG", "DOT", "VET"],
|
||||||
@@ -88,6 +95,7 @@ public class DLSiteClientTests
|
|||||||
|
|
||||||
// RG0001
|
// RG0001
|
||||||
VoiceWorkDetails voiceWorkDetails = mappedCollection["RG0001"];
|
VoiceWorkDetails voiceWorkDetails = mappedCollection["RG0001"];
|
||||||
|
voiceWorkDetails.Title.ShouldBe("Product 1");
|
||||||
voiceWorkDetails.WishlistCount.ShouldBe(250);
|
voiceWorkDetails.WishlistCount.ShouldBe(250);
|
||||||
voiceWorkDetails.DownloadCount.ShouldBe(100);
|
voiceWorkDetails.DownloadCount.ShouldBe(100);
|
||||||
voiceWorkDetails.HasTrial.ShouldBe(true);
|
voiceWorkDetails.HasTrial.ShouldBe(true);
|
||||||
@@ -102,6 +110,7 @@ public class DLSiteClientTests
|
|||||||
|
|
||||||
// RG0002
|
// RG0002
|
||||||
VoiceWorkDetails secondVoiceWorkDetails = mappedCollection["RG0002"];
|
VoiceWorkDetails secondVoiceWorkDetails = mappedCollection["RG0002"];
|
||||||
|
secondVoiceWorkDetails.Title.ShouldBe("Product 2");
|
||||||
secondVoiceWorkDetails.WishlistCount.ShouldBe(100);
|
secondVoiceWorkDetails.WishlistCount.ShouldBe(100);
|
||||||
secondVoiceWorkDetails.DownloadCount.ShouldBe(50);
|
secondVoiceWorkDetails.DownloadCount.ShouldBe(50);
|
||||||
secondVoiceWorkDetails.HasTrial.ShouldBe(true);
|
secondVoiceWorkDetails.HasTrial.ShouldBe(true);
|
||||||
|
|||||||
@@ -219,5 +219,94 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default_point_str": "150"
|
"default_point_str": "150"
|
||||||
|
},
|
||||||
|
"RJ01536422": {
|
||||||
|
"site_id": "home",
|
||||||
|
"site_id_touch": "hometouch",
|
||||||
|
"maker_id": "RG36156",
|
||||||
|
"age_category": 1,
|
||||||
|
"affiliate_deny": 0,
|
||||||
|
"dl_count": null,
|
||||||
|
"wishlist_count": 94,
|
||||||
|
"dl_format": 0,
|
||||||
|
"rank": [],
|
||||||
|
"rate_average": null,
|
||||||
|
"rate_average_2dp": null,
|
||||||
|
"rate_average_star": null,
|
||||||
|
"rate_count": null,
|
||||||
|
"rate_count_detail": [],
|
||||||
|
"review_count": 0,
|
||||||
|
"price": null,
|
||||||
|
"price_without_tax": null,
|
||||||
|
"price_str": "0",
|
||||||
|
"default_point_rate": 5,
|
||||||
|
"default_point": 0,
|
||||||
|
"product_point_rate": null,
|
||||||
|
"dlsiteplay_work": false,
|
||||||
|
"is_ana": true,
|
||||||
|
"is_sale": true,
|
||||||
|
"on_sale": 0,
|
||||||
|
"is_discount": false,
|
||||||
|
"is_pointup": false,
|
||||||
|
"gift": [],
|
||||||
|
"is_rental": false,
|
||||||
|
"work_rentals": [],
|
||||||
|
"upgrade_min_price": 110,
|
||||||
|
"down_url": "https:\/\/www.dlsite.com\/maniax\/download\/=\/product_id\/RJ01536422.html",
|
||||||
|
"is_tartget": null,
|
||||||
|
"title_id": null,
|
||||||
|
"title_name": null,
|
||||||
|
"title_name_masked": null,
|
||||||
|
"title_volumn": null,
|
||||||
|
"title_work_count": null,
|
||||||
|
"is_title_completed": false,
|
||||||
|
"bulkbuy_key": null,
|
||||||
|
"bonuses": [],
|
||||||
|
"is_limit_work": false,
|
||||||
|
"is_sold_out": false,
|
||||||
|
"limit_stock": 0,
|
||||||
|
"is_reserve_work": false,
|
||||||
|
"is_reservable": false,
|
||||||
|
"is_timesale": false,
|
||||||
|
"timesale_stock": 0,
|
||||||
|
"is_free": false,
|
||||||
|
"is_oly": false,
|
||||||
|
"is_led": false,
|
||||||
|
"is_noreduction": false,
|
||||||
|
"is_wcc": false,
|
||||||
|
"translation_info": {
|
||||||
|
"is_translation_agree": false,
|
||||||
|
"is_volunteer": false,
|
||||||
|
"is_original": true,
|
||||||
|
"is_parent": false,
|
||||||
|
"is_child": false,
|
||||||
|
"is_translation_bonus_child": false,
|
||||||
|
"original_workno": null,
|
||||||
|
"parent_workno": null,
|
||||||
|
"child_worknos": [],
|
||||||
|
"lang": null,
|
||||||
|
"production_trade_price_rate": 0,
|
||||||
|
"translation_bonus_langs": [],
|
||||||
|
"translation_status_for_translator": []
|
||||||
|
},
|
||||||
|
"work_name": "\u73c8\u7432\u5c4b \u7db4 \/ \u3044\u3064\u3082\u3044\u3064\u3067\u3082\u301cAlone with you\u301c",
|
||||||
|
"work_name_masked": "\u73c8\u7432\u5c4b \u7db4 \/ \u3044\u3064\u3082\u3044\u3064\u3067\u3082\u301cAlone with you\u301c",
|
||||||
|
"work_image": "\/\/img.dlsite.jp\/modpub\/images2\/ana\/doujin\/RJ01537000\/RJ01536422_ana_img_main.jpg",
|
||||||
|
"sales_end_info": null,
|
||||||
|
"voice_pack": null,
|
||||||
|
"regist_date": "2026-01-01 00:00:00",
|
||||||
|
"locale_price": [],
|
||||||
|
"locale_price_str": [],
|
||||||
|
"currency_price": null,
|
||||||
|
"work_type": "SOU",
|
||||||
|
"book_type": null,
|
||||||
|
"discount_calc_type": null,
|
||||||
|
"is_garumani_general": false,
|
||||||
|
"is_pack_work": false,
|
||||||
|
"limited_free_terms": [],
|
||||||
|
"official_price": null,
|
||||||
|
"options": "JPN#SND",
|
||||||
|
"custom_genres": [],
|
||||||
|
"default_point_str": "0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
@@ -15,27 +15,28 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<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.html" />
|
<EmbeddedResource Include="Scanning\Japanese-Page.html" />
|
||||||
<EmbeddedResource Include="Scanning\English-Page.html" />
|
<EmbeddedResource Include="Scanning\English-Page.html" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
<PackageReference Include="coverlet.collector" Version="8.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.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||||
<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.9.0" />
|
<PackageReference Include="Testcontainers" Version="4.10.0" />
|
||||||
<PackageReference Include="Testcontainers.MariaDb" Version="4.9.0" />
|
<PackageReference Include="Testcontainers.MariaDb" Version="4.10.0" />
|
||||||
<PackageReference Include="Testcontainers.XunitV3" Version="4.9.0" />
|
<PackageReference Include="Testcontainers.XunitV3" Version="4.10.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>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.1" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
183
JSMR.Tests/Scanning/Japanese-Page-Updated.html
Normal file
183
JSMR.Tests/Scanning/Japanese-Page-Updated.html
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<html lang="ja-JP">
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<div id="wrapper">
|
||||||
|
<div id="main">
|
||||||
|
<div id="main_inner">
|
||||||
|
<div>
|
||||||
|
<div data-toggle="found" class="sort_box border_b pb10">
|
||||||
|
<div class="page_total">
|
||||||
|
<strong>6670</strong>
|
||||||
|
<span>件中</span>
|
||||||
|
<strong>1~30</strong>
|
||||||
|
<span>件目</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="search_result_list" class="loading_display_open" data-toggle="found">
|
||||||
|
<table class="work_1col_table n_worklist" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<!-- RJ01536422 (With Image) -->
|
||||||
|
<tr data-list_item_product_id="RJ01536422" class=" ">
|
||||||
|
<td class="work_1col_thumb">
|
||||||
|
<div class="work_thumb">
|
||||||
|
<div class="work_thumb_inner" data-vue-component="thumb-img-popup">
|
||||||
|
<thumb-with-ng-filter-block ref="popup_img"
|
||||||
|
link="https://www.dlsite.com/maniax/announce/=/product_id/RJ01536422.html"
|
||||||
|
:thumb-candidates="['//img.dlsite.jp/resize/images2/ana/doujin/RJ01537000/RJ01536422_ana_img_main_240x240.webp','//img.dlsite.jp/resize/images2/ana/doujin/RJ01537000/RJ01536422_ana_img_main_240x240.jpg']"
|
||||||
|
alt="珈琲屋 綴 / いつもいつでも〜Alone with you〜 [喫茶綴]"
|
||||||
|
@mouseenter="showPopupImg">
|
||||||
|
</thumb-with-ng-filter-block>
|
||||||
|
<div v-cloak class="work_img_popover">
|
||||||
|
<img src="data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7"
|
||||||
|
:src="is_show ? '//img.dlsite.jp/modpub/images2/ana/doujin/RJ01537000/RJ01536422_ana_img_main.jpg' : 'data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7'"
|
||||||
|
alt="珈琲屋 綴 / いつもいつでも〜Alone with you〜 [喫茶綴]">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="work_category type_SOU">
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/work_type/SOU">ボイス・ASMR</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<dl class="work_1col">
|
||||||
|
<dt class="work_name">
|
||||||
|
<p class="expected_date">2026年12月下旬 発売予定</p>
|
||||||
|
<div class="icon_wrap"></div>
|
||||||
|
<a href="https://www.dlsite.com/maniax/announce/=/product_id/RJ01536422.html">
|
||||||
|
珈琲屋 綴 / いつもいつでも〜Alone with you〜
|
||||||
|
</a>
|
||||||
|
</dt>
|
||||||
|
<dd class="maker_name">
|
||||||
|
<a href="https://www.dlsite.com/maniax/circle/profile/=/maker_id/RG36156.html">喫茶綴</a>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<span class="author">
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/keyword_creater/%22%E9%87%8E%E4%B8%8A%E8%8F%9C%E6%9C%88%22/ana_flg/all"
|
||||||
|
class="">野上菜月</a>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd class="work_text">
|
||||||
|
珈琲に特化した喫茶店、喫茶綴、外伝。『珈琲屋 綴』の従業員、綴明日菜が、大好きな珈琲と、貴方との時間を大切に育みます。珈琲に特化した喫茶店、喫茶綴の外伝です。CV:野上菜月様
|
||||||
|
</dd>
|
||||||
|
<dd class="work_genre">
|
||||||
|
<span class="icon_GEN" title="全年齢">全年齢</span><span data-vue-component="product-coupon" data-product_id="RJ01536422" v-cloak></span>
|
||||||
|
<input type="hidden" class="__product_attributes"
|
||||||
|
name="__product_attributes" id="_RJ01536422"
|
||||||
|
value="RG36156,male,SOU,JPN,SND,497,056,496,008,442,058"
|
||||||
|
disabled="disabled">
|
||||||
|
</dd>
|
||||||
|
<dd class="search_tag">
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/497/from/work.genre">ASMR</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/056/from/work.genre">癒し</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/058/from/work.genre">オールハッピー</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/496/from/work.genre">バイノーラル/ダミヘ</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/008/from/work.genre">日常/生活</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/442/from/work.genre">耳かき</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
<td class="work_1col_right">
|
||||||
|
<ul>
|
||||||
|
<li class="sales_date">
|
||||||
|
予告開始日: 2026年01月01日
|
||||||
|
</li>
|
||||||
|
<li class="work_dl clear">
|
||||||
|
<div data-vue-component="product-wishlist-count"
|
||||||
|
data-product_id="RJ01536422" v-cloak></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div data-vue-component="product-item" data-product_id="RJ01536422"
|
||||||
|
data-layout="fsr_announce_only" data-is_ana="1" class="btn_2col"
|
||||||
|
data-usesample="true" data-samples="[]">
|
||||||
|
<p class="work_free_sample">
|
||||||
|
<a href="https://www.dlsite.com/maniax/announce/=/product_id/RJ01536422.html"
|
||||||
|
class="btn_free_sample disabled" data-product-id="RJ01536422">無料サンプル</a>
|
||||||
|
</p>
|
||||||
|
<p class="work_favorite_xs">
|
||||||
|
<a href="https://www.dlsite.com/maniax/mypage/wishlist/=/product_id/RJ01536422.html"
|
||||||
|
class="btn_favorite _btn_favorite" title="お気に入りに追加">お気に入りに追加</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- RJ01393816 (No Image) -->
|
||||||
|
<tr data-list_item_product_id="RJ01393816" class=" ">
|
||||||
|
<td class="work_1col_thumb">
|
||||||
|
<div class="work_thumb">
|
||||||
|
<div class="work_thumb_inner" data-vue-component="thumb-img-popup">
|
||||||
|
<thumb-with-ng-filter-block ref="popup_img" link="https://www.dlsite.com/maniax/announce/=/product_id/RJ01393816.html" :thumb-candidates="['//www.dlsite.com/images/web/home/no_img_main.gif','//www.dlsite.com/images/web/home/no_img_main.gif']" alt="アダルトグッズショップの店長にオナ禁でオモチャにされる話 [平たい胸族]" @mouseenter="showPopupImg"></thumb-with-ng-filter-block>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="work_category type_SOU"><a href="https://www.dlsite.com/maniax/fsr/=/work_type/SOU">ボイス・ASMR</a></div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
<dl class="work_1col">
|
||||||
|
<dt class="work_name">
|
||||||
|
|
||||||
|
<p class="expected_date">
|
||||||
|
2026年12月下旬 発売予定
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="icon_wrap">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<a href="https://www.dlsite.com/maniax/announce/=/product_id/RJ01393816.html">アダルトグッズショップの店長にオナ禁でオモチャにされる話</a>
|
||||||
|
</dt>
|
||||||
|
<dd class="maker_name">
|
||||||
|
<a href="https://www.dlsite.com/maniax/circle/profile/=/maker_id/RG01044380.html">平たい胸族</a>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
|
||||||
|
<dd class="work_text">アダルトグッズショップでダウナーなセンパイと仕事中にオナ禁サポートをしてサボっていたことが店長にバレてしまった。今度はセンパイの詩乃と店長のミチル、2人にオナ禁でオモチャにされることになってしまった。</dd>
|
||||||
|
|
||||||
|
<dd class="work_genre">
|
||||||
|
<span data-vue-component="product-coupon" data-product_id="RJ01393816" v-cloak></span>
|
||||||
|
<input type="hidden" class="__product_attributes" name="__product_attributes" id="_RJ01393816" value="RG01044380,adl,male,SOU,JPN,SND,497,118,183,187,158,496,440,448" disabled="disabled">
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd class="search_tag">
|
||||||
|
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/497/from/work.genre">ASMR</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/496/from/work.genre">バイノーラル/ダミヘ</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/448/from/work.genre">色仕掛け</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/440/from/work.genre">浮気</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/158/from/work.genre">百合</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/118/from/work.genre">レズ/女同士</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/187/from/work.genre">ツルペタ</a>
|
||||||
|
<a href="https://www.dlsite.com/maniax/fsr/=/genre/183/from/work.genre">貧乳/微乳</a>
|
||||||
|
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
<td class="work_1col_right">
|
||||||
|
<ul>
|
||||||
|
<li class="sales_date">
|
||||||
|
予告開始日: 2025年05月15日
|
||||||
|
</li>
|
||||||
|
<li class="work_dl clear">
|
||||||
|
<div data-vue-component="product-wishlist-count" data-product_id="RJ01393816" v-cloak></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div data-vue-component="product-item" data-product_id="RJ01393816" data-layout="fsr_announce_only" data-is_ana="1" class="btn_2col" data-usesample="true" data-samples="[]">
|
||||||
|
<p class="work_free_sample"><a href="https://www.dlsite.com/maniax/announce/=/product_id/RJ01393816.html" class="btn_free_sample disabled" data-product-id="RJ01393816">無料サンプル</a></p>
|
||||||
|
<p class="work_favorite_xs"><a href="https://www.dlsite.com/maniax/mypage/wishlist/=/product_id/RJ01393816.html" class="btn_favorite _btn_favorite" title="お気に入りに追加">お気に入りに追加</a></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
using JSMR.Application.Scanning.Contracts;
|
using JSMR.Application.Integrations.DLSite.Models;
|
||||||
|
using JSMR.Application.Integrations.Ports;
|
||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Application.Scanning.Ports;
|
||||||
using JSMR.Infrastructure.Http;
|
using JSMR.Infrastructure.Http;
|
||||||
using JSMR.Infrastructure.Scanning;
|
using JSMR.Infrastructure.Scanning;
|
||||||
|
using JSMR.Tests.Extensions;
|
||||||
using JSMR.Tests.Utilities;
|
using JSMR.Tests.Utilities;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -20,9 +24,7 @@ public class VoiceWorkScannerTests
|
|||||||
string html = await ReadResourceAsync("Japanese-Page.html");
|
string html = await ReadResourceAsync("Japanese-Page.html");
|
||||||
|
|
||||||
IHttpService httpService = Substitute.For<IHttpService>();
|
IHttpService httpService = Substitute.For<IHttpService>();
|
||||||
|
httpService.ReturnsContent(html);
|
||||||
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
|
|
||||||
.Returns(Task.FromResult(html));
|
|
||||||
|
|
||||||
HtmlLoader loader = new(httpService);
|
HtmlLoader loader = new(httpService);
|
||||||
JapaneseVoiceWorksScanner scanner = new(loader);
|
JapaneseVoiceWorksScanner scanner = new(loader);
|
||||||
@@ -35,7 +37,7 @@ public class VoiceWorkScannerTests
|
|||||||
ExcludedMakerIds: []
|
ExcludedMakerIds: []
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await scanner.ScanPageAsync(options, CancellationToken.None);
|
var result = await scanner.ScanWorksAsync(options);
|
||||||
|
|
||||||
result.Count.ShouldBe(1);
|
result.Count.ShouldBe(1);
|
||||||
|
|
||||||
@@ -49,19 +51,113 @@ public class VoiceWorkScannerTests
|
|||||||
result[0].Creators.ShouldBe(["柚木つばめ"]);
|
result[0].Creators.ShouldBe(["柚木つばめ"]);
|
||||||
result[0].Genres.ShouldBe(["体験版"]);
|
result[0].Genres.ShouldBe(["体験版"]);
|
||||||
result[0].Tags.ShouldBe(["バイノーラル/ダミヘ", "手コキ", "足コキ", "パイズリ", "言葉責め", "焦らし", "乳首責め", "本番なし"]);
|
result[0].Tags.ShouldBe(["バイノーラル/ダミヘ", "手コキ", "足コキ", "パイズリ", "言葉責め", "焦らし", "乳首責め", "本番なし"]);
|
||||||
result[0].Type.ShouldBe(DLSiteWorkType.Released);
|
|
||||||
result[0].Downloads.ShouldBe(1220);
|
result[0].Downloads.ShouldBe(1220);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scan_With_Updated_Japanese_Locale()
|
||||||
|
{
|
||||||
|
string html = await ReadResourceAsync("Japanese-Page-Updated.html");
|
||||||
|
|
||||||
|
IHttpService httpService = Substitute.For<IHttpService>();
|
||||||
|
httpService.ReturnsContent(html);
|
||||||
|
|
||||||
|
HtmlLoader loader = new(httpService);
|
||||||
|
JapaneseVoiceWorksScanner scanner = new(loader);
|
||||||
|
|
||||||
|
VoiceWorkScanOptions options = new(
|
||||||
|
PageNumber: 1,
|
||||||
|
PageSize: 100,
|
||||||
|
ExcludeAIGeneratedWorks: true,
|
||||||
|
ExcludePartiallyAIGeneratedWorks: true,
|
||||||
|
ExcludedMakerIds: []
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await scanner.ScanWorksAsync(options);
|
||||||
|
|
||||||
|
result.Count.ShouldBe(2);
|
||||||
|
|
||||||
|
result[0].SalesDate.ShouldBeNull();
|
||||||
|
result[0].ExpectedDate.ShouldBe(new DateOnly(2026, 12, 21));
|
||||||
|
result[0].ProductId.ShouldBe("RJ01536422");
|
||||||
|
result[0].ProductName.ShouldBe("珈琲屋 綴 / いつもいつでも〜Alone with you〜");
|
||||||
|
result[0].Description.ShouldBe("珈琲に特化した喫茶店、喫茶綴、外伝。『珈琲屋 綴』の従業員、綴明日菜が、大好きな珈琲と、貴方との時間を大切に育みます。珈琲に特化した喫茶店、喫茶綴の外伝です。CV:野上菜月様");
|
||||||
|
result[0].Maker.ShouldBe("喫茶綴");
|
||||||
|
result[0].MakerId.ShouldBe("RG36156");
|
||||||
|
result[0].Creators.ShouldBe(["野上菜月"]);
|
||||||
|
result[0].Genres.ShouldBe(["全年齢"]);
|
||||||
|
result[0].Tags.ShouldBe(["ASMR", "癒し", "オールハッピー", "バイノーラル/ダミヘ", "日常/生活", "耳かき"]);
|
||||||
|
// TODO: Wishlist count?
|
||||||
|
|
||||||
|
result[1].SalesDate.ShouldBeNull();
|
||||||
|
result[1].ExpectedDate.ShouldBe(new DateOnly(2026, 12, 21));
|
||||||
|
result[1].ProductId.ShouldBe("RJ01393816");
|
||||||
|
result[1].ProductName.ShouldBe("アダルトグッズショップの店長にオナ禁でオモチャにされる話");
|
||||||
|
result[1].Description.ShouldBe("アダルトグッズショップでダウナーなセンパイと仕事中にオナ禁サポートをしてサボっていたことが店長にバレてしまった。今度はセンパイの詩乃と店長のミチル、2人にオナ禁でオモチャにされることになってしまった。");
|
||||||
|
result[1].Maker.ShouldBe("平たい胸族");
|
||||||
|
result[1].MakerId.ShouldBe("RG01044380");
|
||||||
|
result[1].Creators.ShouldBe([]);
|
||||||
|
result[1].Genres.ShouldBe([]);
|
||||||
|
result[1].Tags.ShouldBe(["ASMR", "バイノーラル/ダミヘ", "色仕掛け", "浮気", "百合", "レズ/女同士", "ツルペタ", "貧乳/微乳"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Scan) + (Integration) = <This> ==> Ingestion
|
||||||
|
[Fact]
|
||||||
|
public async Task Scan_And_Integration_Ingest_Mapping_Test()
|
||||||
|
{
|
||||||
|
IVoiceWorksScanner scanner = Substitute.For<IVoiceWorksScanner>();
|
||||||
|
|
||||||
|
DLSiteWork[] scannedWorks =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ProductId = "RJ1",
|
||||||
|
ProductName = "Masked Product Title",
|
||||||
|
MakerId = "RG1",
|
||||||
|
Maker = "Some Maker",
|
||||||
|
ImageUrl = "https://site.com/image_main.png",
|
||||||
|
SmallImageUrl = "https://site.com/image_240x240.png"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
VoiceWorkScanResult scanResult = new(
|
||||||
|
Works: scannedWorks,
|
||||||
|
EndOfResults: false
|
||||||
|
);
|
||||||
|
|
||||||
|
scanner.ScanPageAsync(Arg.Any<VoiceWorkScanOptions>(), CancellationToken.None)
|
||||||
|
.Returns(Task.FromResult(scanResult));
|
||||||
|
|
||||||
|
IDLSiteClient dlsiteClient = Substitute.For<IDLSiteClient>();
|
||||||
|
|
||||||
|
VoiceWorkDetailCollection detailCollection = new()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"RJ1",
|
||||||
|
new VoiceWorkDetails()
|
||||||
|
{
|
||||||
|
Title = "Product Title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dlsiteClient.GetVoiceWorkDetailsAsync(Arg.Any<string[]>(), CancellationToken.None)
|
||||||
|
.Returns(Task.FromResult(detailCollection));
|
||||||
|
|
||||||
|
VoiceWorkIngest ingest = VoiceWorkIngest.From(scannedWorks[0], detailCollection["RJ1"]);
|
||||||
|
|
||||||
|
// TODO: Test other fields
|
||||||
|
ingest.Title.ShouldBe("Product Title");
|
||||||
|
ingest.HasImage.ShouldBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Scan_With_English_Locale()
|
public async Task Scan_With_English_Locale()
|
||||||
{
|
{
|
||||||
string englishPageHtml = await ReadResourceAsync("English-Page.html");
|
string englishPageHtml = await ReadResourceAsync("English-Page.html");
|
||||||
|
|
||||||
IHttpService httpService = Substitute.For<IHttpService>();
|
IHttpService httpService = Substitute.For<IHttpService>();
|
||||||
|
httpService.ReturnsContent(englishPageHtml);
|
||||||
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
|
|
||||||
.Returns(Task.FromResult(englishPageHtml));
|
|
||||||
|
|
||||||
HtmlLoader loader = new(httpService);
|
HtmlLoader loader = new(httpService);
|
||||||
EnglishVoiceWorksScanner scanner = new(loader);
|
EnglishVoiceWorksScanner scanner = new(loader);
|
||||||
@@ -74,7 +170,7 @@ public class VoiceWorkScannerTests
|
|||||||
ExcludedMakerIds: []
|
ExcludedMakerIds: []
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await scanner.ScanPageAsync(options, CancellationToken.None);
|
var result = await scanner.ScanWorksAsync(options);
|
||||||
|
|
||||||
result.Count.ShouldBe(2);
|
result.Count.ShouldBe(2);
|
||||||
|
|
||||||
@@ -88,13 +184,11 @@ public class VoiceWorkScannerTests
|
|||||||
result[0].Creators.ShouldBe(["Some Creator"]);
|
result[0].Creators.ShouldBe(["Some Creator"]);
|
||||||
result[0].Genres.ShouldBe(["Voice", "Trial version"]);
|
result[0].Genres.ShouldBe(["Voice", "Trial version"]);
|
||||||
result[0].Tags.ShouldBe(["Male Protagonist", "Gal", "Uniform", "Harem", "Big Breasts", "Tanned Skin / Suntan"]);
|
result[0].Tags.ShouldBe(["Male Protagonist", "Gal", "Uniform", "Harem", "Big Breasts", "Tanned Skin / Suntan"]);
|
||||||
result[0].Type.ShouldBe(DLSiteWorkType.Released);
|
|
||||||
result[0].Downloads.ShouldBe(1000);
|
result[0].Downloads.ShouldBe(1000);
|
||||||
|
|
||||||
result[1].ExpectedDate.ShouldBe(new DateOnly(2025, 10, 11));
|
result[1].ExpectedDate.ShouldBe(new DateOnly(2025, 10, 11));
|
||||||
result[1].SalesDate.ShouldBeNull();
|
result[1].SalesDate.ShouldBeNull();
|
||||||
result[1].ProductId.ShouldBe("RJ00000002");
|
result[1].ProductId.ShouldBe("RJ00000002");
|
||||||
result[1].Type.ShouldBe(DLSiteWorkType.Announced);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -103,9 +197,7 @@ public class VoiceWorkScannerTests
|
|||||||
string html = await ReadResourceAsync("English-Page-Updated.html");
|
string html = await ReadResourceAsync("English-Page-Updated.html");
|
||||||
|
|
||||||
IHttpService httpService = Substitute.For<IHttpService>();
|
IHttpService httpService = Substitute.For<IHttpService>();
|
||||||
|
httpService.ReturnsContent(html);
|
||||||
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
|
|
||||||
.Returns(Task.FromResult(html));
|
|
||||||
|
|
||||||
HtmlLoader loader = new(httpService);
|
HtmlLoader loader = new(httpService);
|
||||||
EnglishVoiceWorksScanner scanner = new(loader);
|
EnglishVoiceWorksScanner scanner = new(loader);
|
||||||
@@ -118,7 +210,7 @@ public class VoiceWorkScannerTests
|
|||||||
ExcludedMakerIds: []
|
ExcludedMakerIds: []
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await scanner.ScanPageAsync(options, CancellationToken.None);
|
var result = await scanner.ScanWorksAsync(options);
|
||||||
|
|
||||||
result.Count.ShouldBe(1);
|
result.Count.ShouldBe(1);
|
||||||
|
|
||||||
@@ -132,7 +224,6 @@ public class VoiceWorkScannerTests
|
|||||||
result[0].Creators.ShouldBe(["沼倉愛美"]);
|
result[0].Creators.ShouldBe(["沼倉愛美"]);
|
||||||
result[0].Genres.ShouldBe(["All Ages", "Trial version"]);
|
result[0].Genres.ShouldBe(["All Ages", "Trial version"]);
|
||||||
result[0].Tags.ShouldBe(["Moe", "Healing", "Binaural", "ASMR", "Ear Cleaning", "Slice of Life / Daily Living", "Heartwarming", "Whispering"]);
|
result[0].Tags.ShouldBe(["Moe", "Healing", "Binaural", "ASMR", "Ear Cleaning", "Slice of Life / Daily Living", "Heartwarming", "Whispering"]);
|
||||||
result[0].Type.ShouldBe(DLSiteWorkType.Released);
|
|
||||||
result[0].Downloads.ShouldBe(1);
|
result[0].Downloads.ShouldBe(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,11 @@ public sealed class VoiceWorkSearchProviderFixture(MariaDbContainerFixture conta
|
|||||||
);
|
);
|
||||||
|
|
||||||
context.VoiceWorks.AddRange(
|
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 },
|
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, SubtitleLanguage = (byte)Language.Japanese, Rating = (byte)AgeRating.AllAges, AIGeneration = (byte)AIGeneration.None },
|
||||||
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 },
|
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, SubtitleLanguage = (byte)Language.Japanese, Rating = (byte)AgeRating.AllAges, AIGeneration = (byte)AIGeneration.None },
|
||||||
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 },
|
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, SubtitleLanguage = (byte)Language.Japanese, Rating = (byte)AgeRating.R18, AIGeneration = (byte)AIGeneration.Partial },
|
||||||
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 },
|
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, SubtitleLanguage = (byte)Language.Japanese, Rating = (byte)AgeRating.AllAges, 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 }
|
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.Tags.AddRange(
|
context.Tags.AddRange(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using JSMR.Application.Common.Search;
|
using JSMR.Application.Common.Search;
|
||||||
using JSMR.Application.VoiceWorks.Queries.Search;
|
using JSMR.Application.VoiceWorks.Queries.Search;
|
||||||
|
using JSMR.Domain.Enums;
|
||||||
using JSMR.Infrastructure.Data;
|
using JSMR.Infrastructure.Data;
|
||||||
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
|
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -651,4 +652,61 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
|
|||||||
.Select(item => item.ProductId)
|
.Select(item => item.ProductId)
|
||||||
.ShouldBe(["RJ0000002"]);
|
.ShouldBe(["RJ0000002"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Filter_Language()
|
||||||
|
{
|
||||||
|
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
|
||||||
|
{
|
||||||
|
Criteria = new()
|
||||||
|
{
|
||||||
|
SupportedLanguages = [Language.English]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await SearchAsync(options);
|
||||||
|
|
||||||
|
result.Items
|
||||||
|
.OrderBy(item => item.ProductId)
|
||||||
|
.Select(item => item.ProductId)
|
||||||
|
.ShouldBe(["RJ0000005"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Filter_Age_Rating()
|
||||||
|
{
|
||||||
|
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
|
||||||
|
{
|
||||||
|
Criteria = new()
|
||||||
|
{
|
||||||
|
AgeRatings = [AgeRating.R15]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await SearchAsync(options);
|
||||||
|
|
||||||
|
result.Items
|
||||||
|
.OrderBy(item => item.ProductId)
|
||||||
|
.Select(item => item.ProductId)
|
||||||
|
.ShouldBe(["RJ0000005"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Filter_AI_Generation()
|
||||||
|
{
|
||||||
|
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
|
||||||
|
{
|
||||||
|
Criteria = new()
|
||||||
|
{
|
||||||
|
AIGenerationOptions = [AIGeneration.Partial]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await SearchAsync(options);
|
||||||
|
|
||||||
|
result.Items
|
||||||
|
.OrderBy(item => item.ProductId)
|
||||||
|
.Select(item => item.ProductId)
|
||||||
|
.ShouldBe(["RJ0000003"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
283
JSMR.Tests/Unit/DLSiteWorkExpectedDateInferenceTests.cs
Normal file
283
JSMR.Tests/Unit/DLSiteWorkExpectedDateInferenceTests.cs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Infrastructure.Scanning.Extensions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Unit;
|
||||||
|
|
||||||
|
public class DLSiteWorkExpectedDateInferenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Infers_From_Sandwich_Expected_Dates()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Infers_From_Sandwich_Sales_Dates_Early()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 2)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 7)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Infers_From_Sandwich_Sales_Dates_Middle()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 12)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 13)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
new DateOnly(2026, 5, 11),
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Infers_From_Sandwich_Sales_Dates_Late()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 25)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 27)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
new DateOnly(2026, 5, 21),
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Infers_From_Sandwich_Mixed_Dates()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 7)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Infers_A_Run_Of_Missing_Items_When_Bounded_By_Same_Date()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(),
|
||||||
|
Work(),
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expected =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_Expected_Sandwich_Difference()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(expected: new DateOnly(2026, 5, 11)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
null,
|
||||||
|
new DateOnly(2026, 5, 11)
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_Sales_Sandwich_Difference_Early_Middle()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 12)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
null,
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_Sales_Sandwich_Difference_Early_Late()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 22)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
null,
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_Sales_Sandwich_Difference_Middle_Late()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(sales: new DateOnly(2026, 5, 14)),
|
||||||
|
Work(),
|
||||||
|
Work(sales: new DateOnly(2026, 5, 22)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null, // Sales date will not have expected date
|
||||||
|
null,
|
||||||
|
null // Sales date will not have expected date
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_No_Left()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(),
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
new DateOnly(2026, 5, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNotInfer_When_No_Right()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work()
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expectedExpectedDates =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expectedExpectedDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoesNot_Overwrite_Existing_ExpectedDate()
|
||||||
|
{
|
||||||
|
DLSiteWork[] works =
|
||||||
|
[
|
||||||
|
Work(expected: new DateOnly(2026, 5, 1)),
|
||||||
|
Work(expected: new DateOnly(2026, 4, 11)), // already set
|
||||||
|
Work(expected: new DateOnly(2026, 5, 21)),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateOnly?[] expected =
|
||||||
|
[
|
||||||
|
new DateOnly(2026, 5, 1),
|
||||||
|
new DateOnly(2026, 4, 11),
|
||||||
|
new DateOnly(2026, 5, 21),
|
||||||
|
];
|
||||||
|
|
||||||
|
AttemptInferAndVerify(works, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AttemptInferAndVerify(DLSiteWork[] works, DateOnly?[] expectedExpectedDates)
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
works.InferAndUpdateExpectedDates();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
works.Length.ShouldBe(expectedExpectedDates.Length);
|
||||||
|
|
||||||
|
for (int i = 0; i < works.Length; i++)
|
||||||
|
works[i].ExpectedDate.ShouldBe(expectedExpectedDates[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DLSiteWork Work(DateOnly? expected = null, DateOnly? sales = null)
|
||||||
|
=> DLSiteWorkTestFactory.Create(expectedDate: expected, salesDate: sales);
|
||||||
|
}
|
||||||
39
JSMR.Tests/Unit/DLSiteWorkTestFactory.cs
Normal file
39
JSMR.Tests/Unit/DLSiteWorkTestFactory.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using JSMR.Application.Scanning.Contracts;
|
||||||
|
using JSMR.Domain.Enums;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Unit;
|
||||||
|
|
||||||
|
internal static class DLSiteWorkTestFactory
|
||||||
|
{
|
||||||
|
private static int _counter = 0;
|
||||||
|
|
||||||
|
public static DLSiteWork Create(DateOnly? expectedDate = null, DateOnly? salesDate = null)
|
||||||
|
{
|
||||||
|
int id = Interlocked.Increment(ref _counter);
|
||||||
|
|
||||||
|
return new DLSiteWork
|
||||||
|
{
|
||||||
|
ProductName = $"Test Product {id}",
|
||||||
|
ProductId = $"RJ_TEST_{id:00000000}",
|
||||||
|
Maker = "Test Maker",
|
||||||
|
MakerId = "RG_TEST",
|
||||||
|
ImageUrl = "//img.dlsite.jp/test_main.jpg",
|
||||||
|
SmallImageUrl = "//img.dlsite.jp/test_sam.jpg",
|
||||||
|
AgeRating = AgeRating.AllAges,
|
||||||
|
|
||||||
|
// Relevant fields for these tests:
|
||||||
|
ExpectedDate = expectedDate,
|
||||||
|
SalesDate = salesDate,
|
||||||
|
|
||||||
|
// The rest can be safe defaults:
|
||||||
|
Downloads = 0,
|
||||||
|
StarRating = null,
|
||||||
|
Votes = null,
|
||||||
|
Description = null,
|
||||||
|
Genres = [],
|
||||||
|
Tags = [],
|
||||||
|
Creators = [],
|
||||||
|
HasTrial = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
<HeadContent>
|
@using JSMR.UI.Blazor.Components.Authentication
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
|
||||||
|
@inject SessionState Session
|
||||||
|
|
||||||
|
<HeadContent>
|
||||||
<RadzenTheme Theme="material-dark" />
|
<RadzenTheme Theme="material-dark" />
|
||||||
</HeadContent>
|
</HeadContent>
|
||||||
|
|
||||||
<Router AppAssembly="@typeof(App).Assembly">
|
<AuthenticationGate>
|
||||||
<Found Context="routeData">
|
<Router AppAssembly="@typeof(App).Assembly">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
<Found Context="routeData">
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||||
</Found>
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
<NotFound>
|
</Found>
|
||||||
<PageTitle>Not found</PageTitle>
|
<NotFound>
|
||||||
<LayoutView Layout="@typeof(MainLayout)">
|
<PageTitle>Not found</PageTitle>
|
||||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
<LayoutView Layout="@typeof(MainLayout)">
|
||||||
</LayoutView>
|
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||||
</NotFound>
|
</LayoutView>
|
||||||
</Router>
|
</NotFound>
|
||||||
|
</Router>
|
||||||
|
</AuthenticationGate>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
//await Session.RefreshAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
|
||||||
|
@inject SessionState Session
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@if (!_ready)
|
||||||
|
{
|
||||||
|
<p>Loading...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
private bool _ready;
|
||||||
|
|
||||||
|
// Add any routes you want public here.
|
||||||
|
// Use absolute-path form (leading slash).
|
||||||
|
private static readonly HashSet<string> _allowAnonymous = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"/login",
|
||||||
|
"/login/", // optional
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
|
||||||
|
// One-time refresh at app start
|
||||||
|
await Session.RefreshAsync();
|
||||||
|
_ready = true;
|
||||||
|
|
||||||
|
await EnforceAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// If your Session can change based on navigation/cookies, you *may* refresh here,
|
||||||
|
// but avoid doing it on every navigation unless necessary.
|
||||||
|
// await Session.RefreshAsync();
|
||||||
|
|
||||||
|
await EnforceAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task EnforceAsync()
|
||||||
|
{
|
||||||
|
if (!_ready) return Task.CompletedTask;
|
||||||
|
|
||||||
|
var path = "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var qIndex = path.IndexOf('?', StringComparison.Ordinal);
|
||||||
|
if (qIndex >= 0) path = path[..qIndex];
|
||||||
|
|
||||||
|
// allow anonymous routes
|
||||||
|
if (_allowAnonymous.Contains(path))
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
if (!Session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
var returnUrl = Uri.EscapeDataString(Navigation.Uri);
|
||||||
|
Navigation.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
=> Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
|
||||||
|
@inject SessionState Session
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@if (!ready)
|
||||||
|
{
|
||||||
|
<p>Loading...</p>
|
||||||
|
}
|
||||||
|
else if (!Session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
<!-- nothing shown, we redirect -->
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
private bool ready;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await Session.RefreshAsync();
|
||||||
|
ready = true;
|
||||||
|
|
||||||
|
if (!Session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
var returnUrl = Uri.EscapeDataString(Nav.Uri);
|
||||||
|
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
@using JSMR.UI.Blazor.Enums
|
@using JSMR.UI.Blazor.Enums
|
||||||
|
|
||||||
<div class="j-chip">
|
@if (string.IsNullOrWhiteSpace(Url))
|
||||||
@if (Graphic != null)
|
{
|
||||||
{
|
<div class="@GetClasses()" @onclick="@OnClickAsync">
|
||||||
<Icon Graphic="@Graphic.Value" Color="@Color"></Icon>
|
@if (Graphic != null)
|
||||||
}
|
{
|
||||||
<span>@ChildContent</span>
|
<Icon Graphic="@Graphic.Value"
|
||||||
</div>
|
Varient="@(IconVarient ?? Enums.IconVarient.None)"
|
||||||
|
Size="@(IconSize ?? Enums.SizeVarient.Small)"
|
||||||
|
Color="@Color">
|
||||||
|
</Icon>
|
||||||
|
}
|
||||||
|
<span>@ChildContent</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="@GetClasses()" href="@Url" target="@Target">
|
||||||
|
@if (Graphic != null)
|
||||||
|
{
|
||||||
|
<Icon
|
||||||
|
Graphic="@Graphic.Value"
|
||||||
|
Varient="@(IconVarient ?? Enums.IconVarient.None)"
|
||||||
|
Size="@(IconSize ?? Enums.SizeVarient.Small)"
|
||||||
|
Color="@Color">
|
||||||
|
</Icon>
|
||||||
|
}
|
||||||
|
<span>@ChildContent</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -15,6 +38,75 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Graphic? Graphic { get; set; }
|
public Graphic? Graphic { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IconVarient? IconVarient { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public SizeVarient? IconSize { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public ColorVarient Color { get; set; } = ColorVarient.Primary;
|
public ColorVarient Color { get; set; } = ColorVarient.Primary;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public ElementVarient Varient { get; set; } = ElementVarient.None;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public ToneVarient Tone { get; set; } = ToneVarient.None;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Target { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback Click { get; set; }
|
||||||
|
|
||||||
|
private string GetClasses()
|
||||||
|
{
|
||||||
|
string color = Color.ToString().ToLower();
|
||||||
|
|
||||||
|
List<string> classNames =
|
||||||
|
[
|
||||||
|
$"j-chip",
|
||||||
|
$"color-{color}"
|
||||||
|
];
|
||||||
|
|
||||||
|
switch (Varient)
|
||||||
|
{
|
||||||
|
case ElementVarient.Filled:
|
||||||
|
classNames.Add($"varient-filled");
|
||||||
|
//classNames.Add($"background-color-{color}");
|
||||||
|
break;
|
||||||
|
case ElementVarient.Outlined:
|
||||||
|
classNames.Add($"varient-outlined");
|
||||||
|
//classNames.Add($"border-color-{color}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (Tone)
|
||||||
|
{
|
||||||
|
case ToneVarient.Solid:
|
||||||
|
classNames.Add($"tone-solid");
|
||||||
|
break;
|
||||||
|
case ToneVarient.Tint:
|
||||||
|
classNames.Add($"tone-tint");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Click.HasDelegate || string.IsNullOrWhiteSpace(Url) == false)
|
||||||
|
{
|
||||||
|
classNames.Add("is-clickable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", classNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnClickAsync()
|
||||||
|
{
|
||||||
|
if (Click.HasDelegate)
|
||||||
|
{
|
||||||
|
await Click.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
43
JSMR.UI.Blazor/Components/Chips/CircleChip.razor
Normal file
43
JSMR.UI.Blazor/Components/Chips/CircleChip.razor
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@using JSMR.Application.Tags.Queries.Search.Contracts
|
||||||
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
|
@using JSMR.UI.Blazor.Enums
|
||||||
|
@using JSMR.UI.Blazor.Filters
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
|
<Chip Graphic="Graphic.Circle" Color="@GetColor()" Varient="ElementVarient.Outlined" Tone="@GetTone()" Url="@GetUrl()" Target="_blank">@Circle.Name</Chip>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public required VoiceWorkCircleItem Circle { get; set; }
|
||||||
|
|
||||||
|
private string GetUrl()
|
||||||
|
{
|
||||||
|
return $"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Circle.MakerId}.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ColorVarient GetColor()
|
||||||
|
{
|
||||||
|
if (Circle.IsFavorite)
|
||||||
|
{
|
||||||
|
return ColorVarient.Mint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Circle.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ColorVarient.Pink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColorVarient.Secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToneVarient GetTone()
|
||||||
|
{
|
||||||
|
if (Circle.IsFavorite || Circle.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ToneVarient.Tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToneVarient.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
JSMR.UI.Blazor/Components/Chips/CreatorChip.razor
Normal file
43
JSMR.UI.Blazor/Components/Chips/CreatorChip.razor
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@using JSMR.Application.Tags.Queries.Search.Contracts
|
||||||
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
|
@using JSMR.UI.Blazor.Enums
|
||||||
|
@using JSMR.UI.Blazor.Filters
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
|
<Chip Graphic="Graphic.Person" IconVarient="IconVarient.Fill" Color="@GetColor()" Varient="ElementVarient.Outlined" Tone="@GetTone()" Url="@GetUrl()" Target="_blank">@Creator.Name</Chip>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public required VoiceWorkCreatorItem Creator { get; set; }
|
||||||
|
|
||||||
|
private string GetUrl()
|
||||||
|
{
|
||||||
|
return $"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{Creator.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ColorVarient GetColor()
|
||||||
|
{
|
||||||
|
if (Creator.IsFavorite)
|
||||||
|
{
|
||||||
|
return ColorVarient.Mint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Creator.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ColorVarient.Pink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColorVarient.Secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToneVarient GetTone()
|
||||||
|
{
|
||||||
|
if (Creator.IsFavorite || Creator.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ToneVarient.Tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToneVarient.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
JSMR.UI.Blazor/Components/Chips/TagChip.razor
Normal file
54
JSMR.UI.Blazor/Components/Chips/TagChip.razor
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@using JSMR.Application.Tags.Queries.Search.Contracts
|
||||||
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
|
@using JSMR.UI.Blazor.Enums
|
||||||
|
@using JSMR.UI.Blazor.Filters
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
|
<Chip Graphic="Graphic.Tag" Color="@GetColor()" Varient="ElementVarient.Outlined" Tone="@GetTone()" Click="@OnClick">@Tag.Name</Chip>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject]
|
||||||
|
protected NavigationManager NavigationManager { get; set; } = default!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public required VoiceWorkTagItem Tag { get; set; }
|
||||||
|
|
||||||
|
private ColorVarient GetColor()
|
||||||
|
{
|
||||||
|
if (Tag.IsFavorite)
|
||||||
|
{
|
||||||
|
return ColorVarient.Mint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Tag.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ColorVarient.Pink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColorVarient.Secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToneVarient GetTone()
|
||||||
|
{
|
||||||
|
if (Tag.IsFavorite || Tag.IsBlacklisted)
|
||||||
|
{
|
||||||
|
return ToneVarient.Tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToneVarient.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClick()
|
||||||
|
{
|
||||||
|
VoiceWorkFilterState state = new()
|
||||||
|
{
|
||||||
|
TagIds = [Tag.TagId]
|
||||||
|
};
|
||||||
|
|
||||||
|
string basePath = new Uri(NavigationManager.Uri).GetLeftPart(UriPartial.Authority);
|
||||||
|
string uri = QueryHelpers.AddQueryString($"{basePath}/voiceworks", state.ToQuery());
|
||||||
|
|
||||||
|
NavigationManager.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
public Graphic Graphic { get; set; }
|
public Graphic Graphic { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public SizeVarient Size { get; set; } = SizeVarient.Medium;
|
public SizeVarient Size { get; set; } = SizeVarient.Small;
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IconVarient Varient { get; set; } = IconVarient.None;
|
public IconVarient Varient { get; set; } = IconVarient.None;
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
[
|
[
|
||||||
$"j-icon",
|
$"j-icon",
|
||||||
$"j-icon-{graphic}",
|
$"j-icon-{graphic}",
|
||||||
$"j-icon-size-{Size.ToString().ToLower()}",
|
$"size-{Size.ToString().ToLower()}",
|
||||||
$"background-color-{Color.ToString().ToLower()}"
|
$"background-color-{Color.ToString().ToLower()}"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="@ContainerClassees">
|
<div class="@ContainerClassees">
|
||||||
<div class="@OverlayClasses"></div>
|
<div class="@OverlayClasses"></div>
|
||||||
<img class="@ImageClasses" loading="@LoadingAttribute" src="@Source" @onload="OnImageLoaded">
|
<img class="@ImageClasses" loading="@LoadingAttribute" src="@currentSource" @onload="OnImageLoaded" @onerror="OnImageError">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
public required string Source { get; set; }
|
public required string Source { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string FallbackSource { get; set; } = "images/home/no_img_main.gif";
|
public string FallbackSource { get; set; } = "images/web/home/not_found_img_main.png"; // "images/web/home/no_img_main.gif";
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool LazyLoading { get; set; } = true;
|
public bool LazyLoading { get; set; } = true;
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string? ImageClass { get; set; }
|
public string? ImageClass { get; set; }
|
||||||
|
|
||||||
|
private string? currentSource;
|
||||||
|
private bool hasSourceErrored = false;
|
||||||
|
private bool hasFallbackSourceErrored = false;
|
||||||
|
|
||||||
private bool _isLoaded;
|
private bool _isLoaded;
|
||||||
private string? _lastSource;
|
private string? _lastSource;
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@
|
|||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
|
currentSource = Source;
|
||||||
|
hasSourceErrored = false;
|
||||||
|
hasFallbackSourceErrored = false;
|
||||||
|
|
||||||
if (!string.Equals(_lastSource, Source, StringComparison.Ordinal))
|
if (!string.Equals(_lastSource, Source, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
_lastSource = Source;
|
_lastSource = Source;
|
||||||
@@ -103,4 +111,20 @@
|
|||||||
{
|
{
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnImageError()
|
||||||
|
{
|
||||||
|
if (!hasSourceErrored && !string.IsNullOrEmpty(FallbackSource))
|
||||||
|
{
|
||||||
|
hasSourceErrored = true;
|
||||||
|
currentSource = FallbackSource;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
else if (!hasFallbackSourceErrored)
|
||||||
|
{
|
||||||
|
hasFallbackSourceErrored = true;
|
||||||
|
currentSource = "images/web/home/not_found_img_main.png";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@using JSMR.Application.VoiceWorks.Queries.Search
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
@using JSMR.Domain.Enums
|
@using JSMR.Domain.Enums
|
||||||
|
@using JSMR.UI.Blazor.Components.Chips
|
||||||
@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
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
|
|
||||||
<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")"></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>
|
||||||
</div>
|
</div>
|
||||||
<div class="j-voice-work-content">
|
<div class="j-voice-work-content">
|
||||||
<div class="j-product-title">
|
<div class="j-product-title">
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
Target="_blank"
|
Target="_blank"
|
||||||
Variant="MudBlazor.Variant.Filled"
|
Variant="MudBlazor.Variant.Filled"
|
||||||
Icon="@Icons.Material.Outlined.Circle">@Product.Maker</MudChip>
|
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"
|
<MudChip T="string"
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
Target="_blank"
|
Target="_blank"
|
||||||
Variant="MudBlazor.Variant.Filled"
|
Variant="MudBlazor.Variant.Filled"
|
||||||
Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip>
|
Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip>
|
||||||
|
@* <CreatorChip Creator="@creator"></CreatorChip> *@
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +42,12 @@
|
|||||||
<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">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@using JSMR.Application.VoiceWorks.Queries.Search
|
@using JSMR.Application.Tags.Queries.Search.Contracts
|
||||||
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
@using JSMR.UI.Blazor.Filters
|
@using JSMR.UI.Blazor.Filters
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
<a class="j-tag" @onclick="@OnClick">@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> *@
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -12,6 +14,25 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public required VoiceWorkTagItem Tag { get; set; }
|
public required VoiceWorkTagItem Tag { get; set; }
|
||||||
|
|
||||||
|
private string Classes => GetClasses();
|
||||||
|
|
||||||
|
private string GetClasses()
|
||||||
|
{
|
||||||
|
List<string> classNames = ["j-tag", "j-tag-2"];
|
||||||
|
|
||||||
|
if (Tag.IsFavorite)
|
||||||
|
{
|
||||||
|
classNames.Add("j-tag-favorite");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Tag.IsBlacklisted)
|
||||||
|
{
|
||||||
|
classNames.Add("j-tag-blacklisted");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", classNames);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnClick()
|
private void OnClick()
|
||||||
{
|
{
|
||||||
VoiceWorkFilterState state = new()
|
VoiceWorkFilterState state = new()
|
||||||
|
|||||||
8
JSMR.UI.Blazor/Dockerfile
Normal file
8
JSMR.UI.Blazor/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish -c Release -o out
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/out/wwwroot /usr/share/nginx/html
|
||||||
|
COPY JSMR.UI.Blazor/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
@@ -16,6 +16,7 @@ public enum ColorVarient
|
|||||||
Black,
|
Black,
|
||||||
Yellow,
|
Yellow,
|
||||||
Green,
|
Green,
|
||||||
|
Mint,
|
||||||
Teal,
|
Teal,
|
||||||
Blue,
|
Blue,
|
||||||
Orange,
|
Orange,
|
||||||
@@ -35,6 +36,7 @@ public static class CssUtil
|
|||||||
ColorVarient.SurfaceContainerOutlineLow => "surface-container-outline-low",
|
ColorVarient.SurfaceContainerOutlineLow => "surface-container-outline-low",
|
||||||
ColorVarient.Yellow => "text-yellow",
|
ColorVarient.Yellow => "text-yellow",
|
||||||
ColorVarient.Green => "text-green",
|
ColorVarient.Green => "text-green",
|
||||||
|
ColorVarient.Mint => "text-mint",
|
||||||
ColorVarient.Teal => "text-teal",
|
ColorVarient.Teal => "text-teal",
|
||||||
ColorVarient.Blue => "text-blue",
|
ColorVarient.Blue => "text-blue",
|
||||||
ColorVarient.Orange => "text-orange",
|
ColorVarient.Orange => "text-orange",
|
||||||
@@ -50,6 +52,7 @@ public static class CssUtil
|
|||||||
ColorVarient.SurfaceContainerLow => "surface-container-low",
|
ColorVarient.SurfaceContainerLow => "surface-container-low",
|
||||||
ColorVarient.Yellow => "text-yellow",
|
ColorVarient.Yellow => "text-yellow",
|
||||||
ColorVarient.Green => "text-green",
|
ColorVarient.Green => "text-green",
|
||||||
|
ColorVarient.Mint => "text-mint",
|
||||||
ColorVarient.Teal => "text-teal",
|
ColorVarient.Teal => "text-teal",
|
||||||
ColorVarient.Blue => "text-blue",
|
ColorVarient.Blue => "text-blue",
|
||||||
ColorVarient.Orange => "text-orange",
|
ColorVarient.Orange => "text-orange",
|
||||||
|
|||||||
8
JSMR.UI.Blazor/Enums/ElementVarient.cs
Normal file
8
JSMR.UI.Blazor/Enums/ElementVarient.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace JSMR.UI.Blazor.Enums;
|
||||||
|
|
||||||
|
public enum ElementVarient
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Filled = 1,
|
||||||
|
Outlined = 2
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public enum Graphic
|
|||||||
Circle,
|
Circle,
|
||||||
Tag,
|
Tag,
|
||||||
Person,
|
Person,
|
||||||
|
Avatar,
|
||||||
Sort,
|
Sort,
|
||||||
Grid,
|
Grid,
|
||||||
Age,
|
Age,
|
||||||
|
|||||||
7
JSMR.UI.Blazor/Enums/ImageExtension.cs
Normal file
7
JSMR.UI.Blazor/Enums/ImageExtension.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace JSMR.UI.Blazor.Enums;
|
||||||
|
|
||||||
|
public enum ImageExtension
|
||||||
|
{
|
||||||
|
Jpeg,
|
||||||
|
WebP
|
||||||
|
}
|
||||||
8
JSMR.UI.Blazor/Enums/ToneVarient.cs
Normal file
8
JSMR.UI.Blazor/Enums/ToneVarient.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace JSMR.UI.Blazor.Enums;
|
||||||
|
|
||||||
|
public enum ToneVarient
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Tint = 1,
|
||||||
|
Solid = 2
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||||
@@ -10,12 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Bit.BlazorUI" Version="10.2.0" />
|
<PackageReference Include="Bit.BlazorUI" Version="10.4.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.4" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.9" />
|
||||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.4" />
|
||||||
<PackageReference Include="Radzen.Blazor" Version="8.3.5" />
|
<PackageReference Include="MudBlazor" Version="9.1.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageReference Include="Radzen.Blazor" Version="9.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
5
JSMR.UI.Blazor/Layout/LoginLayout.razor
Normal file
5
JSMR.UI.Blazor/Layout/LoginLayout.razor
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="login-layout">
|
||||||
|
@Body
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
@inherits LayoutComponentBase
|
@using JSMR.UI.Blazor.Components
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
|
||||||
|
@inject SessionState Session
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Elevation="1" Dense="@_dense">
|
<MudAppBar Elevation="1" Dense="@_dense">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@ToggleDrawer" />
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@ToggleDrawer" />
|
||||||
<MudText>JSMR</MudText>
|
<MudText>JSMR</MudText>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Href="https://github.com/MudBlazor/MudBlazor" Target="_blank" />
|
@* <MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Href="https://github.com/MudBlazor/MudBlazor" Target="_blank" /> *@
|
||||||
|
|
||||||
|
@if (Session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
@* <span>Logged in as <b>@Session.Me?.Name</b> (@Session.Me?.Role)</span> *@
|
||||||
|
<Chip Graphic="Enums.Graphic.Avatar" IconVarient="Enums.IconVarient.Fill" IconSize="Enums.SizeVarient.Small" Color="Enums.ColorVarient.Blue">
|
||||||
|
<span>@Session.Me?.Name @* (@Session.Me?.Role) *@</span>
|
||||||
|
</Chip>
|
||||||
|
<Chip Graphic="Enums.Graphic.Headphones" Color="Enums.ColorVarient.Primary" Click="@LogoutAsync">Logout</Chip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Chip Graphic="Enums.Graphic.Headphones" Color="Enums.ColorVarient.Primary" Url="/login">Login</Chip>
|
||||||
|
}
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
<MudDrawer @bind-Open="@_open" ClipMode="_clipMode" Breakpoint="@_breakpoint" Elevation="1" Variant="@DrawerVariant.Mini">
|
<MudDrawer @bind-Open="@_open" ClipMode="_clipMode" Breakpoint="@_breakpoint" Elevation="1" Variant="@DrawerVariant.Mini">
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
@@ -56,4 +75,27 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Session.Changed += OnSessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSessionChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private async Task OnLogout(MouseEventArgs _)
|
||||||
|
{
|
||||||
|
await Session.LogoutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session.Changed -= OnSessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
await Session.LogoutAsync();
|
||||||
|
Navigation.NavigateTo("/login");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
81
JSMR.UI.Blazor/Pages/Login.razor
Normal file
81
JSMR.UI.Blazor/Pages/Login.razor
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
|
||||||
|
@using JSMR.UI.Blazor.Services
|
||||||
|
|
||||||
|
@inject SessionState Session
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<h3>Login</h3>
|
||||||
|
|
||||||
|
@if (Session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
<p>You're already logged in as <b>@Session.Me?.Name</b>.</p>
|
||||||
|
<button @onclick="Logout">Logout</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="max-width: 360px;">
|
||||||
|
<BitCard>
|
||||||
|
<BitStack>
|
||||||
|
<BitTextField Label="Username" @bind-Value="username"></BitTextField>
|
||||||
|
<BitTextField Label="Password" @bind-Value="password" Type="BitInputType.Password"></BitTextField>
|
||||||
|
<BitButton OnClick="LoginAsync" IsEnabled="@(!busy)">Login</BitButton>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
{
|
||||||
|
<p style="color: crimson; margin-top: 8px;">@error</p>
|
||||||
|
}
|
||||||
|
</BitStack>
|
||||||
|
</BitCard>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string username = "";
|
||||||
|
private string password = "";
|
||||||
|
private bool busy;
|
||||||
|
private string? error;
|
||||||
|
|
||||||
|
private async Task LoginAsync()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ok = await Session.LoginAsync(username, password);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
error = "Invalid username or password.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Nav.NavigateTo("/");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Logout()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Session.LogoutAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@using JSMR.Application.Common.Search
|
@using JSMR.Application.Common.Search
|
||||||
@using JSMR.Application.VoiceWorks.Queries.Search
|
@using JSMR.Application.VoiceWorks.Queries.Search
|
||||||
@using JSMR.UI.Blazor.Components
|
@using JSMR.UI.Blazor.Components
|
||||||
|
@using JSMR.UI.Blazor.Components.Authentication
|
||||||
@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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user