Added additional voice work update logic.

This commit is contained in:
2025-10-11 17:28:39 -04:00
parent db0c3349a2
commit 278b6df650
38 changed files with 56745 additions and 64 deletions

View File

@@ -0,0 +1,7 @@
namespace JSMR.Application.Common;
public interface ISupportedLanguage
{
string Code { get; }
Language Language { get; }
}

View File

@@ -6,10 +6,11 @@ public class VoiceWorkDetails
{
public VoiceWorkSeries? Series { get; init; }
public VoiceWorkTranslation? Translation { get; init; }
public AgeRating AgeRating { get; init; }
public int WishlistCount { get; init; }
public int DownloadCount { get; init; }
public DateTime? RegistrationDate { get; init; }
public Language[] SupportedLanguages { get; init; } = [];
public ISupportedLanguage[] SupportedLanguages { get; init; } = [];
public AIGeneration AI { get; init; }
public bool HasTrial { get; init; }
public bool HasDLPlay { get; init; }

View File

@@ -1,4 +1,6 @@
namespace JSMR.Application.Scanning.Contracts;
using JSMR.Application.Common;
namespace JSMR.Application.Scanning.Contracts;
public class DLSiteWork
{
@@ -19,6 +21,7 @@ public class DLSiteWork
public bool HasTrial { get; set;}
public ICollection<string> Tags { get; set; } = [];
public ICollection<string> Creators { get; set; } = [];
public string? ImageUrl { get; set; }
public string? SmallImageUrl { get; set; }
public required string ImageUrl { get; set; }
public required string SmallImageUrl { get; set; }
public AgeRating AgeRating { get; set; }
}

View File

@@ -1,4 +1,5 @@
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Common;
using JSMR.Application.Integrations.DLSite.Models;
namespace JSMR.Application.Scanning.Contracts;
@@ -17,5 +18,11 @@ public record VoiceWorkIngest(DLSiteWork Work, VoiceWorkDetails? Details)
public bool HasDLPlay { get; init; } = Details?.HasDLPlay ?? false;
public byte? StarRating { get; init; } = Work.StarRating;
public int? Votes { get; init; } = Work.Votes;
public AgeRating AgeRating { get; init; } = Details?.AgeRating ?? Work.AgeRating;
public bool HasImage { get; init; } = Work.ImageUrl.Contains("no_img") == false;
public ICollection<ISupportedLanguage> SupportedLanguages { get; init; } = Details?.SupportedLanguages ?? [];
public DateOnly? ExpectedDate { get; init; } = Work.ExpectedDate;
public DateOnly? SalesDate { get; init; } = Work.SalesDate;
public DateTime? RegistrationDate { get; init; } = Details?.RegistrationDate;
// TODO: Other properties
}

View File

@@ -20,7 +20,7 @@ public class VoiceWork
public bool? IsValid { get; set; }
public DateTime? LastScannedDate { get; set; }
public byte Status { get; set; }
public byte SubtitleLanguage { get; set; }
public byte SubtitleLanguage { get; set; } // TO BE DEPRECATED
public bool HasChobit { get; set; }
public bool IsPurchased { get; set; }
public int? WishlistCount { get; set; }
@@ -36,8 +36,9 @@ public class VoiceWork
//public int? VoiceWorkSearchId { get; set; }
//public VoiceWorkSearch? VoiceWorkSearch { get; set; }
public virtual ICollection<VoiceWorkTag> VoiceWorkTags { get; set; } = [];
public virtual ICollection<VoiceWorkCreator> VoiceWorkCreators { get; set; } = [];
public virtual ICollection<VoiceWorkTag> Tags { get; set; } = [];
public virtual ICollection<VoiceWorkCreator> Creators { get; set; } = [];
public virtual ICollection<EnglishVoiceWork> EnglishVoiceWorks { get; set; } = [];
public virtual ICollection<VoiceWorkSupportedLanguage> SupportedLanguages { get; set; } = [];
public virtual ICollection<VoiceWorkLocalization> Localizations { get; set; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace JSMR.Domain.Entities;
public sealed class VoiceWorkSupportedLanguage
{
public int VoiceWorkSupportedLanguageId { get; set; }
public int VoiceWorkId { get; set; }
public string Language { get; set; } = null!;
public VoiceWork VoiceWork { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.Languages;
public interface ILanguageIdentifier
{
Language GetLanguage(string text);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using JSMR.Application.Common;
using NTextCat;
using System.Reflection;
namespace JSMR.Infrastructure.Common.Languages;
public class LanguageIdentifier : ILanguageIdentifier
{
private readonly string[] _languages =
[
"eng",
"jpn",
"kor",
"zho"
];
private readonly RankedLanguageIdentifier _identifier;
public LanguageIdentifier()
{
RankedLanguageIdentifierFactory factory = new();
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("JSMR.Infrastructure.Languages.Language.xml");
_identifier = factory.Load(stream);
}
public Language GetLanguage(string text)
{
var rankedLanguages = _identifier.Identify(text).Where(x => _languages.Contains(x.Item1.Iso639_3));
var identifiedLanguage = rankedLanguages.OrderBy(x => x.Item2).FirstOrDefault();
if (identifiedLanguage == null)
return Language.Unknown;
return identifiedLanguage.Item1.Iso639_3 switch
{
"jpn" => Language.Japanese,
"eng" => Language.English,
"kor" => Language.Korean,
"zho" => Language.ChineseTraditional,// Or ChineseSimplified?
_ => Language.Unknown,
};
}
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class AlingualLanguage : ISupportedLanguage
{
public Language Language => Language.Unknown;
public string Code => "NM";
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class ChineseLanguage : ISupportedLanguage
{
public Language Language => Language.ChineseTraditional; // ???
public string Code => "CHI";
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class DLSiteOfficialTranslationLanguage : ISupportedLanguage
{
public string Code => "DOT";
public Language Language => Language.Unknown;
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class EnglishLanguage : ISupportedLanguage
{
public Language Language => Language.English;
public string Code => "ENG";
}

View File

@@ -1,6 +0,0 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public interface ISupportedLanguage
{
string Code { get; }
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class JapaneseLanguage : ISupportedLanguage
{
public Language Language => Language.Japanese;
public string Code => "JPN";
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class KoreanLanguage : ISupportedLanguage
{
public Language Language => Language.Korean;
public string Code => "KO_KR";
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class SimplifiedChineseLanguage : ISupportedLanguage
{
public Language Language => Language.ChineseSimplified;
public string Code => "CHI_HANS";
}

View File

@@ -1,6 +1,9 @@
namespace JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Application.Common;
namespace JSMR.Infrastructure.Common.SupportedLanguages;
public class TraditionalChineseLanguage : ISupportedLanguage
{
public Language Language => Language.ChineseTraditional;
public string Code => "CHI_HANT";
}

View File

@@ -0,0 +1,6 @@
namespace JSMR.Infrastructure.Common.Time;
public class Clock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,6 @@
namespace JSMR.Infrastructure.Common.Time;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}

View File

@@ -0,0 +1,69 @@
namespace JSMR.Infrastructure.Common.Time;
public interface ITimeProvider
{
DateTimeOffset Now();
DateTimeOffset Local(int year, int month, int day, int hour);
DateTimeOffset Local(DateTimeOffset offset);
}
public abstract class TimeProvider : ITimeProvider
{
protected abstract string Id { get; }
protected abstract string[] TimeZoneIds { get; }
private readonly IClock _clock;
private readonly TimeZoneInfo _timeZone;
public TimeProvider(IClock clock)
{
_clock = clock;
_timeZone = ResolveTimeZone();
}
private TimeZoneInfo ResolveTimeZone()
{
foreach (string timeZoneId in TimeZoneIds)
{
if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out TimeZoneInfo? timeZoneInfo))
return timeZoneInfo;
}
throw new TimeZoneNotFoundException($"Unable to resolve time zone for: {Id} ({string.Join(" / ", TimeZoneIds)})");
}
public DateTimeOffset Now() => TimeZoneInfo.ConvertTime(_clock.UtcNow, _timeZone);
public DateTimeOffset Local(DateTimeOffset offset) => TimeZoneInfo.ConvertTime(offset, _timeZone);
public DateTimeOffset Local(int year, int month, int day, int hour)
{
DateTime local = new(year, month, day, hour, 0, 0, DateTimeKind.Unspecified);
TimeSpan offset = _timeZone.GetUtcOffset(local);
return new DateTimeOffset(local, offset);
}
public DateTimeOffset CurrentScanAnchor()
{
DateTimeOffset now = Now();
DateTimeOffset midnight = Local(now.Year, now.Month, now.Day, 0);
DateTimeOffset fourPm = Local(now.Year, now.Month, now.Day, 16);
return now >= fourPm ? fourPm : midnight;
}
public DateTimeOffset PreviousScanAnchor(DateTimeOffset scanAnchorTokyo)
{
// Normalize to Tokyo (no-op if already)
var a = TimeZoneInfo.ConvertTime(scanAnchorTokyo, _timeZone);
return a.Hour == 16
? Local(a.Year, a.Month, a.Day, 0)
: Local(a.AddDays(-1).Year, a.AddDays(-1).Month, a.AddDays(-1).Day, 16);
}
}
public class TokyoTimeProvider(IClock clock) : TimeProvider(clock)
{
protected override string Id => "Tokyo Standard Time";
protected override string[] TimeZoneIds => ["Tokyo Standard Time", "Asia/Tokyo"];
}

View File

@@ -5,7 +5,6 @@ using JSMR.Application.Common;
using JSMR.Application.Common.Caching;
using JSMR.Application.Creators.Ports;
using JSMR.Application.Creators.Queries.Search.Ports;
using JSMR.Application.Integrations.Ports;
using JSMR.Application.Scanning.Ports;
using JSMR.Application.Tags.Ports;
using JSMR.Application.Tags.Queries.Search.Ports;
@@ -13,12 +12,14 @@ using JSMR.Application.VoiceWorks.Ports;
using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Infrastructure.Caching;
using JSMR.Infrastructure.Caching.Adapters;
using JSMR.Infrastructure.Common.Languages;
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.Tags;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Integrations.DLSite;
using JSMR.Infrastructure.Scanning;
using Microsoft.Extensions.DependencyInjection;
@@ -38,6 +39,12 @@ public static class InfrastructureServiceCollectionExtensions
services.AddKeyedScoped<IVoiceWorksScanner, JapaneseVoiceWorksScanner>(Locale.Japanese);
services.AddKeyedScoped<IVoiceWorksScanner, EnglishVoiceWorksScanner>(Locale.English);
services.AddKeyedScoped<ISupportedLanguage, JapaneseLanguage>(Locale.Japanese);
services.AddKeyedScoped<ISupportedLanguage, EnglishLanguage>(Locale.English);
services.AddKeyedScoped<ISupportedLanguage, SimplifiedChineseLanguage>(Locale.ChineseSimplified);
services.AddKeyedScoped<ISupportedLanguage, TraditionalChineseLanguage>(Locale.ChineseTraditional);
services.AddKeyedScoped<ISupportedLanguage, KoreanLanguage>(Locale.Korean);
services.AddScoped<ITagSearchProvider, TagSearchProvider>();
services.AddScoped<ITagWriter, TagWriter>();
@@ -55,6 +62,11 @@ public static class InfrastructureServiceCollectionExtensions
services.AddScoped<IHttpService, HttpService>();
services.AddScoped<IHtmlLoader, HtmlLoader>();
services.AddSingleton<ILanguageIdentifier, LanguageIdentifier>();
services.AddSingleton<IClock, Clock>();
services.AddSingleton<ITimeProvider, TokyoTimeProvider>();
return services;
}

View File

@@ -7,6 +7,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
{
public DbSet<VoiceWork> VoiceWorks { get; set; }
public DbSet<EnglishVoiceWork> EnglishVoiceWorks { get; set; }
public DbSet<VoiceWorkSupportedLanguage> VoiceWorkSupportedLanguages { get; set; }
public DbSet<VoiceWorkLocalization> VoiceWorkLocalizations { get; set; }
public DbSet<Circle> Circles { get; set; }
public DbSet<Tag> Tags { get; set; }

View File

@@ -12,7 +12,7 @@ public sealed class VoiceWorkCreatorConfiguration : IEntityTypeConfiguration<Voi
builder.HasKey(x => new { x.VoiceWorkId, x.CreatorId });
builder.HasOne(x => x.VoiceWork)
.WithMany(v => v.VoiceWorkCreators)
.WithMany(v => v.Creators)
.HasForeignKey(x => x.VoiceWorkId)
.OnDelete(DeleteBehavior.Cascade);

View File

@@ -0,0 +1,24 @@
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace JSMR.Infrastructure.Data.Configuration;
public sealed class VoiceWorkSupportedLanguageConfiguration : IEntityTypeConfiguration<VoiceWorkSupportedLanguage>
{
public void Configure(EntityTypeBuilder<VoiceWorkSupportedLanguage> builder)
{
builder.ToTable("voice_work_supported_languages");
builder.HasKey(x => x.VoiceWorkSupportedLanguageId);
builder.Property(x => x.Language).IsRequired().HasMaxLength(10);
builder.HasOne(x => x.VoiceWork)
.WithMany(v => v.SupportedLanguages)
.HasForeignKey(x => x.VoiceWorkId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => new { x.VoiceWorkId, x.Language }).IsUnique();
builder.HasIndex(x => x.VoiceWorkId);
}
}

View File

@@ -12,7 +12,7 @@ public sealed class VoiceWorkTagConfiguration : IEntityTypeConfiguration<VoiceWo
builder.HasKey(x => new { x.VoiceWorkId, x.TagId });
builder.HasOne(x => x.VoiceWork)
.WithMany(v => v.VoiceWorkTags)
.WithMany(v => v.Tags)
.HasForeignKey(x => x.VoiceWorkId)
.OnDelete(DeleteBehavior.Cascade);

View File

@@ -11,9 +11,9 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
{
List<VoiceWork> batch = await dbContext.VoiceWorks
.Include(vw => vw.Circle)
.Include(vw => vw.VoiceWorkTags)
.Include(vw => vw.Tags)
.ThenInclude(vwt => vwt.Tag)
.Include(vw => vw.VoiceWorkCreators)
.Include(vw => vw.Creators)
.ThenInclude(vwc => vwc.Creator)
.Include(vw => vw.EnglishVoiceWorks)
.Where(vw => voiceWorkIds.Contains(vw.VoiceWorkId))
@@ -71,7 +71,7 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
AppendRaw(sb, voiceWork.Description);
AppendRaw(sb, voiceWork.Circle?.Name);
foreach (var tag in voiceWork.VoiceWorkTags.Select(vwt => vwt.Tag))
foreach (var tag in voiceWork.Tags.Select(vwt => vwt.Tag))
{
if (tag is null)
continue;
@@ -86,7 +86,7 @@ public class VoiceWorkSearchUpdater(AppDbContext dbContext) : IVoiceWorkSearchUp
AppendRaw(sb, englishTag?.Name);
}
foreach (var creator in voiceWork.VoiceWorkCreators.Select(vwc => vwc.Creator))
foreach (var creator in voiceWork.Creators.Select(vwc => vwc.Creator))
{
if (creator is null)
continue;

View File

@@ -3,6 +3,8 @@
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
public record VoiceWorkUpsertContext(
DateTimeOffset CurrentScanAnchor,
DateTimeOffset PreviousScanAnchor,
Dictionary<string, Circle> Circles,
Dictionary<string, VoiceWork> VoiceWorks,
Dictionary<string, Tag> Tags,

View File

@@ -1,12 +1,14 @@
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Common;
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Ports;
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Common.Time;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
public class VoiceWorkWriter(AppDbContext dbContext, ITimeProvider timeProvider) : IVoiceWorkWriter
{
public async Task<int[]> UpsertAsync(IReadOnlyCollection<VoiceWorkIngest> ingests, CancellationToken cancellationToken)
{
@@ -29,14 +31,21 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
string[] tagNames = [.. ingests.SelectMany(i => i.Tags).Distinct()];
string[] creatorNames = [.. ingests.SelectMany(i => i.Creators).Distinct()];
DateTimeOffset currentScanAnchor = GetCurrentScanAnchor();
DateTimeOffset previousScanAnchor = PreviousScanAnchor(currentScanAnchor);
VoiceWorkUpsertContext upsertContext = new(
CurrentScanAnchor: currentScanAnchor,
PreviousScanAnchor: previousScanAnchor,
Circles: await dbContext.Circles
.Where(c => makerIds.Contains(c.MakerId))
.ToDictionaryAsync(c => c.MakerId, cancellationToken),
VoiceWorks: await dbContext.VoiceWorks
.Where(v => productIds.Contains(v.ProductId))
.Include(v => v.VoiceWorkCreators)
.Include(v => v.VoiceWorkTags)
.Include(v => v.Creators)
.Include(v => v.Tags)
.Include(v => v.Localizations)
.Include(v => v.SupportedLanguages)
.ToDictionaryAsync(v => v.ProductId, cancellationToken),
Tags: await dbContext.Tags
.Where(t => tagNames.Contains(t.Name))
@@ -49,6 +58,25 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
return upsertContext;
}
private DateTimeOffset GetCurrentScanAnchor()
{
DateTimeOffset now = timeProvider.Now();
DateTimeOffset midnight = timeProvider.Local(now.Year, now.Month, now.Day, 0);
DateTimeOffset fourPm = timeProvider.Local(now.Year, now.Month, now.Day, 16);
return now >= fourPm ? fourPm : midnight;
}
private DateTimeOffset PreviousScanAnchor(DateTimeOffset scanAnchorTokyo)
{
// Normalize to Tokyo (no-op if already)
var a = timeProvider.Local(scanAnchorTokyo);
return a.Hour == 16
? timeProvider.Local(a.Year, a.Month, a.Day, 0)
: timeProvider.Local(a.AddDays(-1).Year, a.AddDays(-1).Month, a.AddDays(-1).Day, 16);
}
private void Upsert(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
UpsertCircle(ingest, upsertContext);
@@ -57,6 +85,7 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
UpsertVoiceWorkTags(ingest, upsertContext);
UpsertCreators(ingest, upsertContext);
UpsertVoiceWorkCreators(ingest, upsertContext);
UpsertVoiceWorkSupportedLanguages(ingest, upsertContext);
}
private void UpsertCircle(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
@@ -85,23 +114,40 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
private void UpsertVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
VoiceWork voiceWork = GetOrAddVoiceWork(ingest, upsertContext);
bool isAdded = dbContext.Entry(voiceWork).State == EntityState.Added;
bool isWithinCurrentScanAnchor = voiceWork.LastScannedDate == upsertContext.CurrentScanAnchor;
bool isNewOnSale = voiceWork.SalesDate is null && ingest.SalesDate is not null;
bool isNew = isAdded || isWithinCurrentScanAnchor || isNewOnSale;
voiceWork.Circle = upsertContext.Circles[ingest.MakerId];
voiceWork.ProductName = ingest.Title;
voiceWork.Description = ingest.Description;
voiceWork.HasImage = ingest.HasImage;
voiceWork.Rating = (int)ingest.AgeRating;
voiceWork.Downloads = ingest.Downloads;
voiceWork.WishlistCount = ingest.WishlistCount;
voiceWork.HasTrial = ingest.HasTrial;
voiceWork.HasChobit = ingest.HasDLPlay;
voiceWork.StarRating = ingest.StarRating;
voiceWork.Votes = ingest.Votes;
voiceWork.IsValid = true;
//var est = i.EstimatedDate?.ToDateTime(new TimeOnly(0, 0));
//if (vw2.ExpectedDate != est) { vw2.ExpectedDate = est; changed = true; }
//var sales = i.SalesDate?.ToDateTime(new TimeOnly(0, 0));
//if (vw2.SalesDate != sales) { vw2.SalesDate = sales; changed = true; }
if (ingest.SalesDate.HasValue)
{
voiceWork.SalesDate = ingest.SalesDate.Value.ToDateTime(new TimeOnly(0, 0));
voiceWork.ExpectedDate = null;
voiceWork.PlannedReleaseDate = null;
voiceWork.Status = isNew ? (byte)VoiceWorkStatus.NewRelease : (byte)VoiceWorkStatus.Available;
}
else
{
voiceWork.SalesDate = null;
voiceWork.ExpectedDate = ingest.ExpectedDate?.ToDateTime(new TimeOnly(0, 0));
voiceWork.PlannedReleaseDate = ingest.RegistrationDate > upsertContext.CurrentScanAnchor ? ingest.RegistrationDate : null;
voiceWork.Status = isNew ? (byte)VoiceWorkStatus.NewAndUpcoming : (byte)VoiceWorkStatus.Upcoming;
}
}
private VoiceWork GetOrAddVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
@@ -144,17 +190,17 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
return tag;
}
private void UpsertCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext something)
private void UpsertCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
foreach (string creatorName in ingest.Creators)
{
GetOrAddCreator(creatorName, something);
GetOrAddCreator(creatorName, upsertContext);
}
}
private Creator GetOrAddCreator(string creatorName, VoiceWorkUpsertContext something)
private Creator GetOrAddCreator(string creatorName, VoiceWorkUpsertContext upsertContext)
{
if (!something.Creators.TryGetValue(creatorName, out Creator? creator))
if (!upsertContext.Creators.TryGetValue(creatorName, out Creator? creator))
{
creator = new Creator
{
@@ -162,22 +208,22 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
};
dbContext.Creators.Add(creator);
something.Creators[creatorName] = creator;
upsertContext.Creators[creatorName] = creator;
}
return creator;
}
private void UpsertVoiceWorkTags(VoiceWorkIngest ingest, VoiceWorkUpsertContext something)
private void UpsertVoiceWorkTags(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
VoiceWork voiceWork = something.VoiceWorks[ingest.ProductId];
Dictionary<int, VoiceWorkTag> existingTagLinks = voiceWork.VoiceWorkTags.ToDictionary(x => x.TagId);
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
Dictionary<int, VoiceWorkTag> existingTagLinks = voiceWork.Tags.ToDictionary(x => x.TagId);
int position = 1;
foreach (string tagName in ingest.Tags)
{
Tag tag = something.Tags[tagName];
Tag tag = upsertContext.Tags[tagName];
if (!existingTagLinks.TryGetValue(tag.TagId, out VoiceWorkTag? voiceWorkTag))
{
@@ -195,16 +241,16 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
}
}
private void UpsertVoiceWorkCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext something)
private void UpsertVoiceWorkCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
VoiceWork voiceWork = something.VoiceWorks[ingest.ProductId];
Dictionary<int, VoiceWorkCreator> existingCreatorLinks = voiceWork.VoiceWorkCreators.ToDictionary(x => x.CreatorId);
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
Dictionary<int, VoiceWorkCreator> existingCreatorLinks = voiceWork.Creators.ToDictionary(x => x.CreatorId);
int position = 1;
foreach (string creatorName in ingest.Creators)
{
Creator creator = something.Creators[creatorName];
Creator creator = upsertContext.Creators[creatorName];
if (!existingCreatorLinks.TryGetValue(creator.CreatorId, out VoiceWorkCreator? voiceWorkCreator))
{
@@ -222,6 +268,26 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
}
}
private void UpsertVoiceWorkSupportedLanguages(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
{
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
Dictionary<string, VoiceWorkSupportedLanguage> existingLanguageLinks = voiceWork.SupportedLanguages.ToDictionary(x => x.Language);
foreach (ISupportedLanguage supportedLanguage in ingest.SupportedLanguages)
{
if (!existingLanguageLinks.TryGetValue(supportedLanguage.Code, out VoiceWorkSupportedLanguage? voiceWorkSupportedLanguage))
{
voiceWorkSupportedLanguage = new VoiceWorkSupportedLanguage
{
VoiceWork = voiceWork,
Language = supportedLanguage.Code
};
dbContext.VoiceWorkSupportedLanguages.Add(voiceWorkSupportedLanguage);
}
}
}
public async Task<SetVoiceWorkFavoriteResponse> SetFavoriteAsync(SetVoiceWorkFavoriteRequest request, CancellationToken cancellationToken)
{
VoiceWork voiceWork = await GetVoiceWorkAsync(request.VoiceWorkId, cancellationToken);

View File

@@ -1,5 +1,6 @@
using JSMR.Application.Common;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Infrastructure.Integrations.DLSite.Models;
namespace JSMR.Infrastructure.Integrations.DLSite.Mapping;
@@ -23,9 +24,25 @@ public static class DLSiteToDomainMapper
("CHI_HANS", Language.ChineseSimplified)
];
private static readonly (string Code, ISupportedLanguage Lang)[] SupportedLanguageFlags2 =
[
("JPN", new JapaneseLanguage()),
("ENG", new EnglishLanguage()),
("CHI", new ChineseLanguage()),
("CHI_HANT", new TraditionalChineseLanguage()),
("CHI_HANS", new SimplifiedChineseLanguage())
];
private static readonly Dictionary<string, Language> TranslationLanguageMap =
SupportedLanguageFlags.ToDictionary(x => x.Code, x => x.Lang, StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<int, AgeRating> AgeRatingMap = new()
{
{ 1, AgeRating.AllAges },
{ 2, AgeRating.R15 },
{ 3, AgeRating.R18 }
};
public static VoiceWorkDetailCollection Map(ProductInfoCollection? productInfoCollection)
{
VoiceWorkDetailCollection result = [];
@@ -63,7 +80,8 @@ public static class DLSiteToDomainMapper
AI = MapAIGeneration(optionsSet),
HasTrial = optionsSet.Contains(OptTrial),
HasDLPlay = optionsSet.Contains(OptDLPlay),
HasReviews = optionsSet.Contains(OptReviews)
HasReviews = optionsSet.Contains(OptReviews),
AgeRating = MapAgeRating(productInfo)
};
}
@@ -105,11 +123,11 @@ public static class DLSiteToDomainMapper
};
}
private static Language[] MapSupportedLanguages(HashSet<string> options)
private static ISupportedLanguage[] MapSupportedLanguages(HashSet<string> options)
{
List<Language> languages = [];
List<ISupportedLanguage> languages = [];
foreach (var (code, language) in SupportedLanguageFlags)
foreach (var (code, language) in SupportedLanguageFlags2)
{
if (options.Contains(code) && !languages.Contains(language))
languages.Add(language);
@@ -128,4 +146,12 @@ public static class DLSiteToDomainMapper
return AIGeneration.None;
}
private static AgeRating MapAgeRating(ProductInfo productInfo)
{
if (AgeRatingMap.TryGetValue(productInfo.AgeCategory, out AgeRating ageRating))
return ageRating;
return AgeRating.R18;
}
}

View File

@@ -7,12 +7,21 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
<None Remove="Common\Languages\Language.xml" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Common\Languages\Language.xml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NTextCat" Version="0.3.65" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>

View File

@@ -1,4 +1,5 @@
using JSMR.Infrastructure.Common.Locales;
using JSMR.Application.Common;
using JSMR.Infrastructure.Common.Locales;
using JSMR.Infrastructure.Common.SupportedLanguages;
namespace JSMR.Infrastructure.Scanning;

View File

@@ -1,4 +1,5 @@
using JSMR.Infrastructure.Common.Locales;
using JSMR.Application.Common;
using JSMR.Infrastructure.Common.Locales;
using JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Infrastructure.Http;
using System.Globalization;

View File

@@ -1,4 +1,5 @@
using JSMR.Infrastructure.Common.Locales;
using JSMR.Application.Common;
using JSMR.Infrastructure.Common.Locales;
using JSMR.Infrastructure.Common.SupportedLanguages;
using JSMR.Infrastructure.Http;
using System.Text.RegularExpressions;

View File

@@ -1,4 +1,5 @@
using HtmlAgilityPack;
using JSMR.Application.Common;
using JSMR.Application.Scanning.Contracts;
using JSMR.Application.Scanning.Ports;
using JSMR.Infrastructure.Common.Locales;
@@ -7,6 +8,7 @@ using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Scanning.Models;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace JSMR.Infrastructure.Scanning;
@@ -90,7 +92,8 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
ImageUrl = imageUrl,
Type = imageUrl.Contains("ana/doujin") ? DLSiteWorkType.Announced : DLSiteWorkType.Released,
StarRating = rating?.Score,
Votes = rating?.Votes
Votes = rating?.Votes,
AgeRating = GetAgeRating(node.GenreNodes)
};
if (node.ExpectedDateNode != null)
@@ -111,6 +114,19 @@ public abstract class VoiceWorksScanner(IHtmlLoader htmlLoader) : IVoiceWorksSca
return work;
}
private static AgeRating GetAgeRating(List<HtmlNode> genreNodes)
{
List<string> genres = ScannerUtilities.GetStringListFromNodes(genreNodes);
if (genres.Contains("全年齢"))
return AgeRating.AllAges;
if (genres.Contains("R-15"))
return AgeRating.R15;
return AgeRating.R18;
}
private static ScannedRating? GetScannedRating(HtmlNode starRatingNode)
{
if (starRatingNode == null)

View File

@@ -0,0 +1,201 @@
using JSMR.Application.Common;
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Scanning.Contracts;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Fixtures;
public class VoiceWorkUpsertFixture : MariaDbFixture
{
protected override async Task OnInitializedAsync(AppDbContext context)
{
await SeedAsync(context);
}
private static async Task SeedAsync(AppDbContext context)
{
if (await context.VoiceWorks.AnyAsync())
return;
context.Circles.AddRange(
new() { CircleId = 1, Name = "Good Dreams", MakerId = "RG00001" },
new() { CircleId = 2, Name = "Sweet Dreams", Favorite = true, MakerId = "RG00002" },
new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" }
);
context.VoiceWorks.AddRange(
new() { VoiceWorkId = 1, CircleId = 1, ProductId = "RJ0000001", ProductName = "Today Sounds", Description = "An average product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 1), Downloads = 500, WishlistCount = 750, StarRating = 35 },
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 = 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 = 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 = 5, CircleId = 2, ProductId = "RJ0000005", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!", Status = (byte)VoiceWorkStatus.NewAndUpcoming, ExpectedDate = new(2025, 1, 11), WishlistCount = 10000 }
);
context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "OL" },
new() { TagId = 3, Name = "ほのぼの" },
new() { TagId = 4, Name = "エルフ/妖精" },
new() { TagId = 5, Name = "ツンデレ", Favorite = true },
new() { TagId = 6, Name = "オールハッピー" },
new() { TagId = 7, Name = "ギャル" },
new() { TagId = 8, Name = "メイド" },
new() { TagId = 9, Name = "ノンフィクション/体験談", Blacklisted = true }
);
context.EnglishTags.AddRange(
new() { EnglishTagId = 1, TagId = 1, Name = "ASMR" },
new() { EnglishTagId = 2, TagId = 2, Name = "Office Lady" },
new() { EnglishTagId = 3, TagId = 3, Name = "Heartwarming" },
new() { EnglishTagId = 4, TagId = 4, Name = "Elf / Fairy" },
new() { EnglishTagId = 5, TagId = 5, Name = "Tsundere" },
new() { EnglishTagId = 6, TagId = 6, Name = "All Happy" },
new() { EnglishTagId = 7, TagId = 7, Name = "Gal" },
new() { EnglishTagId = 8, TagId = 8, Name = "Maid" },
new() { EnglishTagId = 9, TagId = 9, Name = "Non-Fiction / Narrative" }
);
context.VoiceWorkTags.AddRange(
new() { VoiceWorkId = 1, TagId = 1 }, // ASMR
new() { VoiceWorkId = 1, TagId = 2 }, // Office Lady
new() { VoiceWorkId = 2, TagId = 1 }, // ASMR
new() { VoiceWorkId = 2, TagId = 3 }, // Heartwarming
new() { VoiceWorkId = 2, TagId = 4 }, // Elf / Fairy
new() { VoiceWorkId = 2, TagId = 5 }, // Tsundere
new() { VoiceWorkId = 2, TagId = 6 }, // All Happy
new() { VoiceWorkId = 2, TagId = 7 }, // Gal
new() { VoiceWorkId = 2, TagId = 8 }, // Maid
new() { VoiceWorkId = 3, TagId = 5 }, // Tsundere
new() { VoiceWorkId = 3, TagId = 9 } // Non-Fiction / Narrative
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 5 } // Tsundere
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 }
);
context.Creators.AddRange(
new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true },
new() { CreatorId = 2, Name = "秋野かえで" },
new() { CreatorId = 3, Name = "柚木つばめ" },
new() { CreatorId = 4, Name = "逢坂成美" },
new() { CreatorId = 5, Name = "山田じぇみ子", Blacklisted = true }
);
context.VoiceWorkCreators.AddRange(
new() { VoiceWorkId = 1, CreatorId = 2 }, // 秋野かえで
new() { VoiceWorkId = 2, CreatorId = 1 }, // 陽向葵ゅか
new() { VoiceWorkId = 3, CreatorId = 5 }, // 山田じぇみ子
new() { VoiceWorkId = 3, CreatorId = 1 }, // 陽向葵ゅか
new() { VoiceWorkId = 4, CreatorId = 3 }, // 柚木つばめ
new() { VoiceWorkId = 5, CreatorId = 1 }, // 陽向葵ゅか
new() { VoiceWorkId = 5, CreatorId = 4 } // 逢坂成美
);
// <Product Id> <Maker Id> <Circle Name> <Product Name> <Product Description> <Tags> <Creators>
context.VoiceWorkSearches.AddRange(
new() { VoiceWorkId = 1, SearchText = "RJ0000001 RG00001 Good Dreams Today Sounds An average product. ASMR Office Lady" },
new() { VoiceWorkId = 2, SearchText = "RJ0000002 RG00002 Sweet Dreams Super Comfy ASMR An amazing product! ASMR Heartwarming Elf / Fairy Tsundere All Happy Gal Maid" },
new() { VoiceWorkId = 3, SearchText = "RJ0000003 RG00003 Nightmare Fuel Low Effort A bad product." },
new() { VoiceWorkId = 4, SearchText = "RJ0000004 RG00001 Good Dreams Tomorrow Sounds A average upcoming product." },
new() { VoiceWorkId = 5, SearchText = "RJ0000005 RG00002 Sweet Dreams Super Comfy ASMR+ All your favorite sounds, plus more!" }
);
await context.SaveChangesAsync();
}
public VoiceWorkIngest[] GetFirstRoundIngests()
{
DLSiteWork work1 = new()
{
ProductId = "001",
ProductName = "fdsfs",
MakerId = "RG00001",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
};
VoiceWorkDetails details1 = new()
{
AgeRating = AgeRating.R18,
};
VoiceWorkIngest[] ingests =
[
new(work1, details1)
{
MakerId = "RG00001",
MakerName = "Good Dreams",
ProductId = "RJ0000001",
Title = "Today Sounds",
Description = "An average product.",
Tags = ["ASMR", "Office Lady"],
AgeRating = AgeRating.R15,
SalesDate = new DateOnly(2025, 1, 1),
ExpectedDate = null,
WishlistCount = 750,
Downloads = 500,
HasTrial = true,
HasDLPlay = true
},
new(work1, details1)
{
MakerId = "RG00002",
MakerName = "Sweet Dreams",
ProductId = "RJ0000002",
Title = "Super Comfy ASMR",
Description = "An amazing product!",
AgeRating = AgeRating.AllAges,
Tags = ["ASMR", "Heartwarming", "Elf / Fairy", "Tsundere", "All Happy", "Gal", "Maid"],
SalesDate = new DateOnly(2025, 3, 1),
ExpectedDate = null,
WishlistCount = 12000,
Downloads = 5000,
HasTrial = true,
HasDLPlay = true
},
new(work1, details1)
{
MakerId = "RG00003",
MakerName = "Nightmare Fuel",
ProductId = "RJ0000003",
Title = "Low Effort",
Description = "A bad product.",
Tags = ["Tsundere", "Non-Fiction / Narrative"],
AgeRating = AgeRating.R18,
SalesDate = new DateOnly(2025, 1, 1),
ExpectedDate = null,
WishlistCount = 100,
Downloads = 50,
HasTrial = true,
HasDLPlay = false
}
];
return ingests;
}
}

View File

@@ -0,0 +1,47 @@
using JSMR.Application.Integrations.DLSite.Models;
using JSMR.Application.Scanning.Contracts;
using JSMR.Infrastructure.Common.Time;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Tests.Fixtures;
using NSubstitute;
using Shouldly;
namespace JSMR.Tests.Integration;
public class VoiceWorkUpsertTests(VoiceWorkUpsertFixture fixture) : IClassFixture<VoiceWorkUpsertFixture>
{
[Fact]
public async Task Filter_None()
{
await using AppDbContext context = fixture.CreateDbContext();
ITimeProvider timeProvider = Substitute.For<ITimeProvider>();
timeProvider.Now().Returns(new DateTimeOffset(2025, 10, 1, 0, 0, 0, 0, TimeSpan.FromSeconds(0)));
DLSiteWork work1 = new()
{
ProductId = "001",
ProductName = "fdsfs",
MakerId = "RG00001",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
};
VoiceWorkDetails details1 = new()
{
AgeRating = Application.Common.AgeRating.R18,
};
VoiceWorkIngest[] ingests =
[
new(work1, details1)
];
VoiceWorkWriter writer = new(context, timeProvider);
await writer.UpsertAsync(ingests, CancellationToken.None);
context.VoiceWorks.Count().ShouldBe(2);
}
}

View File

@@ -22,14 +22,14 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testcontainers" Version="4.7.0" />
<PackageReference Include="Testcontainers.MariaDb" Version="4.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>