using JSMR.Application.Common; using JSMR.Application.Scanning.Contracts; using JSMR.Application.Scanning.Ports; using JSMR.Domain.Entities; 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 UpsertAsync(IReadOnlyCollection 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; } Upsert(ingest, upsertContext); } await dbContext.SaveChangesAsync(cancellationToken); return [.. upsertContext.VoiceWorks.Select(x => x.Value.VoiceWorkId)]; } private async Task CreateUpsertContextAsync(IReadOnlyCollection 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()]; 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), 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.MakerId, 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 parsed ingest does not"; result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error)); } } private void Upsert(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) { UpsertCircle(ingest, upsertContext); UpsertVoiceWork(ingest, upsertContext); UpsertTags(ingest, upsertContext); UpsertVoiceWorkTags(ingest, upsertContext); UpsertCreators(ingest, upsertContext); UpsertVoiceWorkCreators(ingest, upsertContext); UpsertVoiceWorkSupportedLanguages(ingest, upsertContext); } 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 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; 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) { 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 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 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 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 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); } } } }