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 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; } result.Status = Upsert(ingest, upsertContext); } await dbContext.SaveChangesAsync(cancellationToken); return [.. upsertContext.Results.Select(x => x.Value)]; } 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()]; 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 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 (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; } }