456 lines
16 KiB
C#
456 lines
16 KiB
C#
using JSMR.Application.Scanning.Contracts;
|
|
using JSMR.Application.Scanning.Ports;
|
|
using JSMR.Domain.Entities;
|
|
using JSMR.Domain.Enums;
|
|
using JSMR.Domain.ValueObjects;
|
|
using JSMR.Infrastructure.Common.Time;
|
|
using JSMR.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JSMR.Infrastructure.Ingestion;
|
|
|
|
public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider) : IVoiceWorkUpdater
|
|
{
|
|
public async Task<VoiceWorkUpsertResult[]> UpsertAsync(IReadOnlyCollection<VoiceWorkIngest> ingests, CancellationToken cancellationToken)
|
|
{
|
|
VoiceWorkUpsertContext upsertContext = await CreateUpsertContextAsync(ingests, cancellationToken);
|
|
|
|
foreach (VoiceWorkIngest ingest in ingests)
|
|
{
|
|
Validate(ingest, upsertContext);
|
|
|
|
VoiceWorkUpsertResult result = upsertContext.Results[ingest.ProductId];
|
|
|
|
if (result.Issues.Count > 0)
|
|
{
|
|
result.Status = VoiceWorkUpsertStatus.Skipped;
|
|
continue;
|
|
}
|
|
|
|
result.Status = Upsert(ingest, upsertContext);
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
return [.. upsertContext.Results.Select(x => x.Value)];
|
|
}
|
|
|
|
private async Task<VoiceWorkUpsertContext> CreateUpsertContextAsync(IReadOnlyCollection<VoiceWorkIngest> ingests, CancellationToken cancellationToken)
|
|
{
|
|
string[] makerIds = [.. ingests.Select(i => i.MakerId).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()];
|
|
string[] productIds = [.. ingests.Select(i => i.ProductId).Distinct()];
|
|
string[] tagNames = [.. ingests.SelectMany(i => i.Tags).Distinct()];
|
|
string[] creatorNames = [.. ingests.SelectMany(i => i.Creators).Distinct()];
|
|
string[] seriesIdentifiers = [.. ingests.Where(i => i.Series is not null).Select(i => i.Series!.Identifier).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.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))
|
|
.ToDictionaryAsync(t => t.Name, cancellationToken),
|
|
Creators: await dbContext.Creators
|
|
.Where(cr => creatorNames.Contains(cr.Name))
|
|
.ToDictionaryAsync(cr => cr.Name, cancellationToken),
|
|
Series: await dbContext.Series
|
|
.Where(s => seriesIdentifiers.Contains(s.Identifier))
|
|
.ToDictionaryAsync(s => s.Identifier, cancellationToken),
|
|
Results: productIds.ToDictionary(
|
|
productId => productId,
|
|
productId => new VoiceWorkUpsertResult()
|
|
)
|
|
);
|
|
|
|
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 static void Validate(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
VoiceWorkUpsertResult result = upsertContext.Results[ingest.ProductId];
|
|
|
|
if (upsertContext.Circles.TryGetValue(ingest.MakerId, out Circle? circle) == false)
|
|
return;
|
|
|
|
if (circle.Spam)
|
|
{
|
|
string message = $"Circle {ingest.MakerName} ({ingest.MakerId}) is a spam circle";
|
|
result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error));
|
|
return;
|
|
}
|
|
|
|
if (upsertContext.VoiceWorks.TryGetValue(ingest.ProductId, out VoiceWork? voiceWork) == false)
|
|
return;
|
|
|
|
int ingestDownloads = ingest.Downloads;
|
|
int voiceWorkDownloads = voiceWork.Downloads ?? 0;
|
|
|
|
if (ingestDownloads < voiceWorkDownloads)
|
|
{
|
|
string message = $"Downloads have decreased from {voiceWorkDownloads} to {ingestDownloads}";
|
|
result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error));
|
|
}
|
|
|
|
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";
|
|
result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error));
|
|
}
|
|
}
|
|
|
|
private VoiceWorkUpsertStatus Upsert(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
UpsertCircle(ingest, upsertContext);
|
|
|
|
VoiceWork voiceWork = UpsertVoiceWork(ingest, upsertContext);
|
|
|
|
UpsertTags(ingest, upsertContext);
|
|
UpsertVoiceWorkTags(ingest, upsertContext);
|
|
UpsertCreators(ingest, upsertContext);
|
|
UpsertVoiceWorkCreators(ingest, upsertContext);
|
|
UpsertVoiceWorkSupportedLanguages(ingest, upsertContext);
|
|
UpsertSeries(ingest, upsertContext);
|
|
|
|
return dbContext.Entry(voiceWork).State switch
|
|
{
|
|
EntityState.Added => VoiceWorkUpsertStatus.Inserted,
|
|
EntityState.Modified => VoiceWorkUpsertStatus.Updated,
|
|
_ => VoiceWorkUpsertStatus.Unchanged,
|
|
};
|
|
}
|
|
|
|
private void UpsertCircle(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
Circle circle = GetOrAddCircle(ingest, upsertContext);
|
|
circle.Name = ingest.MakerName;
|
|
}
|
|
|
|
private Circle GetOrAddCircle(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if (!upsertContext.Circles.TryGetValue(ingest.MakerId, out Circle? circle))
|
|
{
|
|
circle = new Circle
|
|
{
|
|
MakerId = ingest.MakerId,
|
|
Name = ingest.MakerName,
|
|
};
|
|
|
|
dbContext.Circles.Add(circle);
|
|
upsertContext.Circles[ingest.MakerId] = circle;
|
|
}
|
|
|
|
return circle;
|
|
}
|
|
|
|
private VoiceWork UpsertVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
VoiceWork voiceWork = GetOrAddVoiceWork(ingest, upsertContext);
|
|
VoiceWorkUpsertState state = ComputeVoiceWorkUpsertState(voiceWork, ingest, upsertContext);
|
|
|
|
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.OriginalProductId = ingest.Translation?.OriginalProductId;
|
|
voiceWork.SubtitleLanguage = GetSubtitleLanguage(ingest);
|
|
voiceWork.AIGeneration = (byte)ingest.AI;
|
|
voiceWork.IsValid = true;
|
|
voiceWork.LastScannedDate = ComputeLastScannedDate(voiceWork.LastScannedDate, state, upsertContext);
|
|
|
|
if (ingest.SalesDate.HasValue)
|
|
{
|
|
voiceWork.SalesDate = ingest.SalesDate.Value.ToDateTime(new TimeOnly(0, 0));
|
|
voiceWork.ExpectedDate = null;
|
|
voiceWork.PlannedReleaseDate = null;
|
|
voiceWork.Status = state.IsNewOnSale ? (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 = state.IsNewUpcoming ? (byte)VoiceWorkStatus.NewAndUpcoming : (byte)VoiceWorkStatus.Upcoming;
|
|
}
|
|
|
|
return voiceWork;
|
|
}
|
|
|
|
private VoiceWork GetOrAddVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if (!upsertContext.VoiceWorks.TryGetValue(ingest.ProductId, out VoiceWork? voiceWork))
|
|
{
|
|
voiceWork = new VoiceWork
|
|
{
|
|
ProductId = ingest.ProductId
|
|
};
|
|
|
|
dbContext.VoiceWorks.Add(voiceWork);
|
|
upsertContext.VoiceWorks[ingest.ProductId] = voiceWork;
|
|
}
|
|
|
|
return voiceWork;
|
|
}
|
|
|
|
private sealed record VoiceWorkUpsertState(
|
|
bool WentOnSale,
|
|
bool IsNewUpcoming,
|
|
bool IsNewOnSale
|
|
);
|
|
|
|
private VoiceWorkUpsertState ComputeVoiceWorkUpsertState(VoiceWork voiceWork, VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
bool isAdded = dbContext.Entry(voiceWork).State == EntityState.Added;
|
|
|
|
DateTime currentScanAnchor = upsertContext.CurrentScanAnchor.DateTime;
|
|
DateTime previousScanAnchor = upsertContext.PreviousScanAnchor.DateTime;
|
|
|
|
bool scannedThis = voiceWork.LastScannedDate == currentScanAnchor;
|
|
bool scannedPrevAt4pm = voiceWork.LastScannedDate == previousScanAnchor && previousScanAnchor.Hour == 16;
|
|
bool hasSales = ingest.SalesDate is not null;
|
|
bool wentOnSale = voiceWork.SalesDate is null && hasSales;
|
|
|
|
bool isNewUpcoming = !hasSales && (isAdded || scannedThis || scannedPrevAt4pm);
|
|
bool isNewOnSale = hasSales && (isAdded || wentOnSale || scannedThis);
|
|
|
|
return new VoiceWorkUpsertState(
|
|
WentOnSale: wentOnSale,
|
|
IsNewUpcoming: isNewUpcoming,
|
|
IsNewOnSale: isNewOnSale
|
|
);
|
|
}
|
|
|
|
private static DateTime? ComputeLastScannedDate(DateTime? existing, VoiceWorkUpsertState state, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if ((state.IsNewUpcoming || state.IsNewOnSale) == false)
|
|
return null;
|
|
|
|
var current = upsertContext.CurrentScanAnchor.DateTime;
|
|
|
|
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)
|
|
{
|
|
foreach (string tagName in ingest.Tags)
|
|
{
|
|
GetOrAddTag(tagName, upsertContext);
|
|
}
|
|
}
|
|
|
|
private Tag GetOrAddTag(string tagName, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if (!upsertContext.Tags.TryGetValue(tagName, out Tag? tag))
|
|
{
|
|
tag = new Tag
|
|
{
|
|
Name = tagName
|
|
};
|
|
|
|
dbContext.Tags.Add(tag);
|
|
upsertContext.Tags[tagName] = tag;
|
|
}
|
|
|
|
return tag;
|
|
}
|
|
|
|
private void UpsertCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
foreach (string creatorName in ingest.Creators)
|
|
{
|
|
GetOrAddCreator(creatorName, upsertContext);
|
|
}
|
|
}
|
|
|
|
private Creator GetOrAddCreator(string creatorName, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if (!upsertContext.Creators.TryGetValue(creatorName, out Creator? creator))
|
|
{
|
|
creator = new Creator
|
|
{
|
|
Name = creatorName
|
|
};
|
|
|
|
dbContext.Creators.Add(creator);
|
|
upsertContext.Creators[creatorName] = creator;
|
|
}
|
|
|
|
return creator;
|
|
}
|
|
|
|
private void UpsertVoiceWorkTags(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
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 = upsertContext.Tags[tagName];
|
|
|
|
if (!existingTagLinks.TryGetValue(tag.TagId, out VoiceWorkTag? voiceWorkTag))
|
|
{
|
|
voiceWorkTag = new VoiceWorkTag
|
|
{
|
|
VoiceWork = voiceWork,
|
|
Tag = tag
|
|
};
|
|
|
|
dbContext.VoiceWorkTags.Add(voiceWorkTag);
|
|
}
|
|
|
|
voiceWorkTag.Position = position++;
|
|
voiceWorkTag.IsValid = true;
|
|
}
|
|
}
|
|
|
|
private void UpsertVoiceWorkCreators(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
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 = upsertContext.Creators[creatorName];
|
|
|
|
if (!existingCreatorLinks.TryGetValue(creator.CreatorId, out VoiceWorkCreator? voiceWorkCreator))
|
|
{
|
|
voiceWorkCreator = new VoiceWorkCreator
|
|
{
|
|
VoiceWork = voiceWork,
|
|
Creator = creator
|
|
};
|
|
|
|
dbContext.VoiceWorkCreators.Add(voiceWorkCreator);
|
|
}
|
|
|
|
voiceWorkCreator.Position = position++;
|
|
voiceWorkCreator.IsValid = true;
|
|
}
|
|
}
|
|
|
|
private void UpsertVoiceWorkSupportedLanguages(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
|
|
Dictionary<string, VoiceWorkSupportedLanguage> existingLanguageLinks = voiceWork.SupportedLanguages.ToDictionary(x => x.Language);
|
|
|
|
foreach (SupportedLanguage supportedLanguage in ingest.SupportedLanguages)
|
|
{
|
|
if (!existingLanguageLinks.TryGetValue(supportedLanguage.Code, out VoiceWorkSupportedLanguage? voiceWorkSupportedLanguage))
|
|
{
|
|
voiceWorkSupportedLanguage = new VoiceWorkSupportedLanguage
|
|
{
|
|
VoiceWork = voiceWork,
|
|
Language = supportedLanguage.Code
|
|
};
|
|
|
|
dbContext.VoiceWorkSupportedLanguages.Add(voiceWorkSupportedLanguage);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpsertSeries(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
Series? series = TryGetOrAddSeries(ingest, upsertContext);
|
|
|
|
VoiceWork voiceWork = upsertContext.VoiceWorks[ingest.ProductId];
|
|
voiceWork.Series = series;
|
|
}
|
|
|
|
private Series? TryGetOrAddSeries(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext)
|
|
{
|
|
if (ingest.Series is null)
|
|
return null;
|
|
|
|
Circle circle = upsertContext.Circles[ingest.MakerId];
|
|
|
|
if (!upsertContext.Series.TryGetValue(ingest.Series.Identifier, out Series? series))
|
|
{
|
|
series = new Series
|
|
{
|
|
Name = ingest.Series.Name,
|
|
Identifier = ingest.Series.Identifier,
|
|
Circle = circle
|
|
};
|
|
|
|
dbContext.Series.Add(series);
|
|
upsertContext.Series[ingest.Series.Identifier] = series;
|
|
}
|
|
else
|
|
{
|
|
series.Identifier = ingest.Series.Identifier;
|
|
}
|
|
|
|
return series;
|
|
}
|
|
} |