Added inital job entity and services. Added released works API integration.
All checks were successful
ci / build-test (push) Successful in 2m21s
ci / publish-image (push) Successful in 2m19s

This commit is contained in:
2026-03-27 01:32:39 -04:00
parent 1c016ac62e
commit d9e421178f
36 changed files with 5596 additions and 43 deletions

View File

@@ -5,7 +5,9 @@ using JSMR.Application.Common.Caching;
using JSMR.Application.Creators.Ports;
using JSMR.Application.Creators.Queries.Search.Ports;
using JSMR.Application.Enums;
using JSMR.Application.Integrations.Ports;
using JSMR.Application.Integrations.Chobit.Ports;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Jobs;
using JSMR.Application.Scanning.Ports;
using JSMR.Application.Tags.Ports;
using JSMR.Application.Tags.Queries.Search.Ports;
@@ -19,6 +21,7 @@ using JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Infrastructure.Common.Time;
using JSMR.Infrastructure.Data.Repositories.Circles;
using JSMR.Infrastructure.Data.Repositories.Creators;
using JSMR.Infrastructure.Data.Repositories.Jobs;
using JSMR.Infrastructure.Data.Repositories.Tags;
using JSMR.Infrastructure.Data.Repositories.Users;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
@@ -50,6 +53,8 @@ public static class InfrastructureServiceCollectionExtensions
services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English);
services.AddScoped<IVoiceWorkScannerRepository, VoiceWorkScannerRepository>();
services.AddScoped<IReleasedWorksProvider, ReleasedWorksProvider>();
services.AddKeyedScoped<IVoiceWorkUpdater, VoiceWorkUpdater>(Locale.Japanese);
services.AddKeyedScoped<IVoiceWorkUpdater, EnglishVoiceWorkUpdater>(Locale.English);
services.AddScoped<IVoiceWorkUpdaterRepository, VoiceWorkUpdaterRepository>();
@@ -68,6 +73,9 @@ public static class InfrastructureServiceCollectionExtensions
services.AddScoped<ICreatorSearchProvider, CreatorSearchProvider>();
services.AddScoped<ICreatorWriter, CreatorWriter>();
services.AddScoped<IJobRepository, JobRepository>();
services.AddScoped<IJobProgressWriter, JobProgressWriter>();
services.AddSingleton<ICache, MemoryCacheAdapter>();
services.AddSingleton<ISpamCircleCache, SpamCircleCache>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
using JSMR.Application.Integrations.Chobit.Models;
using JSMR.Application.Integrations.Ports;
using JSMR.Application.Integrations.Chobit.Ports;
using JSMR.Infrastructure.Http;
using Microsoft.Extensions.Logging;

View File

@@ -1,8 +1,11 @@
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Integrations.Ports;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Infrastructure.Globalization;
using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Integrations.DLSite.Mapping;
using JSMR.Infrastructure.Integrations.DLSite.Models;
using JSMR.Infrastructure.Integrations.DLSite.Models.NewWorks;
using Microsoft.Extensions.Logging;
namespace JSMR.Infrastructure.Integrations.DLSite;
@@ -23,4 +26,23 @@ public class DLSiteClient(HttpClient http, ILogger<DLSiteClient> logger) : ApiCl
return DLSiteToDomainMapper.Map(productInfoCollection);
}
public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(ReleasedWorksRequest request, CancellationToken cancellationToken = default)
{
string locale = LocaleMapper.ToDLSiteLocale(request.Locale);
string date = request.Date.ToString("yyyy-MM-dd");
string url = $"maniax/new/work/api?locale={locale}&date={date}&period={request.Period}";
NewWorksApiResponse response = await GetJsonAsync<NewWorksApiResponse>(url, cancellationToken: cancellationToken);
if (response.Meta.Code != 200)
{
throw new InvalidOperationException(
$"DLsite returned code {response.Meta.Code}. " +
$"ErrorType: {response.Meta.ErrorType}. " +
$"ErrorMessage: {response.Meta.ErrorMessage}");
}
return DLSiteReleasedWorksMapper.Map(response);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,4 +35,8 @@
<ProjectReference Include="..\JSMR.Domain\JSMR.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Jobs\" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
using JSMR.Application.Enums;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports;
namespace JSMR.Infrastructure.Scanning;
public class ReleasedWorksProvider(IDLSiteClient dlsiteClient) : IReleasedWorksProvider
{
public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
{
DateOnly[] salesDates =
[
.. scanResult.Works
.Where(x => x.SalesDate.HasValue)
.Select(x => x.SalesDate!.Value)
];
if (salesDates.Length == 0)
return [];
DateOnly minDate = salesDates.Min();
DateOnly maxDate = salesDates.Max();
DateOnly requestDate = minDate.AddDays(-1);
DateOnly requestEndDate = maxDate.AddDays(1);
int period = (requestEndDate.DayNumber - requestDate.DayNumber) + 1;
ReleasedWorksRequest releasedWorksRequest = new(
Locale: Locale.English,
Date: requestDate,
Period: period
);
return await dlsiteClient.GetReleasedWorksAsync(releasedWorksRequest, cancellationToken);
}
}