using FictionArchive.Common.Enums; using FictionArchive.Service.FileService.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.Shared.Services.EventBus; using HtmlAgilityPack; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace FictionArchive.Service.NovelService.Services; public class NovelUpdateService { private readonly NovelServiceDbContext _dbContext; private readonly ILogger _logger; private readonly IEnumerable _sourceAdapters; private readonly IEventBus _eventBus; private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration; public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IEventBus eventBus, IOptions novelUpdateServiceConfiguration) { _dbContext = dbContext; _logger = logger; _sourceAdapters = sourceAdapters; _eventBus = eventBus; _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; } #region Helper Methods private async Task GetOrCreateSource(SourceDescriptor descriptor) { var existingSource = await _dbContext.Sources .FirstOrDefaultAsync(s => s.Key == descriptor.Key); if (existingSource != null) { return existingSource; } return new Source { Name = descriptor.Name, Url = descriptor.Url, Key = descriptor.Key }; } private Person GetOrCreateAuthor( string authorName, string? authorUrl, Language rawLanguage, Person? existingAuthor) { // Case 1: No existing author - create new if (existingAuthor == null) { return new Person { Name = LocalizationKey.CreateFromText(authorName, rawLanguage), ExternalUrl = authorUrl }; } // Case 2: ExternalUrl differs - create new Person if (existingAuthor.ExternalUrl != authorUrl) { return new Person { Name = LocalizationKey.CreateFromText(authorName, rawLanguage), ExternalUrl = authorUrl }; } // Case 3: Same URL - update name if different UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage); return existingAuthor; } private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language) { var existingText = key.Texts.FirstOrDefault(t => t.Language == language); if (existingText != null) { existingText.Text = newText; } else { key.Texts.Add(new LocalizationText { Language = language, Text = newText }); } } private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata) { UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage); UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage); novel.RawStatus = metadata.RawStatus; novel.Url = metadata.Url; } private async Task> SynchronizeTags( List sourceTags, List systemTags, Language rawLanguage) { var allTagKeys = sourceTags.Concat(systemTags).ToHashSet(); // Query existing tags from DB by Key var existingTagsInDb = await _dbContext.Tags .Where(t => allTagKeys.Contains(t.Key)) .ToListAsync(); var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key); var result = new List(); // Process source tags foreach (var tagKey in sourceTags) { if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag)) { result.Add(existingTag); } else { result.Add(new NovelTag { Key = tagKey, DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage), TagType = TagType.External }); } } // Process system tags foreach (var tagKey in systemTags) { if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag)) { result.Add(existingTag); } else { result.Add(new NovelTag { Key = tagKey, DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage), TagType = TagType.System }); } } return result; } private static List SynchronizeChapters( List metadataChapters, Language rawLanguage, List? existingChapters) { existingChapters ??= new List(); var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet(); // Only add chapters that don't already exist (by Order) var newChapters = metadataChapters .Where(mc => !existingOrderSet.Contains(mc.Order)) .Select(mc => new Chapter { Order = mc.Order, Url = mc.Url, Revision = mc.Revision, Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage), Body = new LocalizationKey { Texts = new List() } }) .ToList(); // Combine existing chapters with new ones return existingChapters.Concat(newChapters).ToList(); } private static (Image? image, bool shouldPublishEvent) HandleCoverImage( ImageData? newCoverData, Image? existingCoverImage) { // Case 1: No new cover image - keep existing or null if (newCoverData == null) { return (existingCoverImage, false); } // Case 2: New cover, no existing if (existingCoverImage == null) { var newImage = new Image { OriginalPath = newCoverData.Url }; return (newImage, true); } // Case 3: Both exist - check if URL changed if (existingCoverImage.OriginalPath != newCoverData.Url) { existingCoverImage.OriginalPath = newCoverData.Url; existingCoverImage.NewPath = null; // Reset uploaded path return (existingCoverImage, true); } // Case 4: Same cover URL - no change needed return (existingCoverImage, false); } private async Task CreateNewNovel(NovelMetadata metadata, Source source) { var author = new Person { Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage), ExternalUrl = metadata.AuthorUrl }; var tags = await SynchronizeTags( metadata.SourceTags, metadata.SystemTags, metadata.RawLanguage); var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null); var novel = new Novel { Author = author, RawLanguage = metadata.RawLanguage, Url = metadata.Url, ExternalId = metadata.ExternalId, CoverImage = metadata.CoverImage != null ? new Image { OriginalPath = metadata.CoverImage.Url } : null, Chapters = chapters, Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), RawStatus = metadata.RawStatus, Tags = tags, Source = source }; _dbContext.Novels.Add(novel); return novel; } #endregion public async Task ImportNovel(string novelUrl) { // Step 1: Get metadata from source adapter NovelMetadata? metadata = null; foreach (ISourceAdapter sourceAdapter in _sourceAdapters) { if (await sourceAdapter.CanProcessNovel(novelUrl)) { metadata = await sourceAdapter.GetMetadata(novelUrl); break; // Stop after finding adapter } } if (metadata == null) { throw new NotSupportedException("The provided novel url is currently unsupported."); } // Step 2: Resolve or create Source var source = await GetOrCreateSource(metadata.SourceDescriptor); // Step 3: Check for existing novel by ExternalId + Source.Key var existingNovel = await _dbContext.Novels .Include(n => n.Author) .ThenInclude(a => a.Name) .ThenInclude(lk => lk.Texts) .Include(n => n.Source) .Include(n => n.Name) .ThenInclude(lk => lk.Texts) .Include(n => n.Description) .ThenInclude(lk => lk.Texts) .Include(n => n.Tags) .Include(n => n.Chapters) .Include(n => n.CoverImage) .FirstOrDefaultAsync(n => n.ExternalId == metadata.ExternalId && n.Source.Key == metadata.SourceDescriptor.Key); Novel novel; bool shouldPublishCoverEvent; if (existingNovel == null) { // CREATE PATH: New novel novel = await CreateNewNovel(metadata, source); shouldPublishCoverEvent = novel.CoverImage != null; } else { // UPDATE PATH: Existing novel novel = existingNovel; // Update author novel.Author = GetOrCreateAuthor( metadata.AuthorName, metadata.AuthorUrl, metadata.RawLanguage, existingNovel.Author); // Update metadata (Name, Description, RawStatus) UpdateNovelMetadata(novel, metadata); // Synchronize tags (remove old, add new, reuse existing) novel.Tags = await SynchronizeTags( metadata.SourceTags, metadata.SystemTags, metadata.RawLanguage); // Synchronize chapters (add only) novel.Chapters = SynchronizeChapters( metadata.Chapters, metadata.RawLanguage, existingNovel.Chapters); // Handle cover image (novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage( metadata.CoverImage, existingNovel.CoverImage); } await _dbContext.SaveChangesAsync(); // Publish cover image event if needed if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) { await _eventBus.Publish(new FileUploadRequestCreatedEvent { RequestId = novel.CoverImage.Id, FileData = metadata.CoverImage.Data, FilePath = $"Novels/{novel.Id}/Images/cover.jpg" }); } return novel; } public async Task PullChapterContents(uint novelId, uint chapterNumber) { var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId) .Include(novel => novel.Chapters) .ThenInclude(chapter => chapter.Body) .ThenInclude(body => body.Texts) .Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images) .FirstOrDefaultAsync(); var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault(); var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key); var rawChapter = await adapter.GetRawChapter(chapter.Url); var localizationText = new LocalizationText() { Text = rawChapter.Text, Language = novel.RawLanguage }; chapter.Body.Texts.Add(localizationText); chapter.Images = rawChapter.ImageData.Select(img => new Image() { OriginalPath = img.Url }).ToList(); await _dbContext.SaveChangesAsync(); // Images are saved and have ids, update the chapter body to replace image tags var chapterDoc = new HtmlDocument(); chapterDoc.LoadHtml(rawChapter.Text); foreach (var image in chapter.Images) { var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@src='{image.OriginalPath}']"); if (match != null) { match.Attributes["src"].Value = _novelUpdateServiceConfiguration.PendingImageUrl; if (match.Attributes.Contains("alt")) { match.Attributes["alt"].Value = image.Id.ToString(); } else { match.Attributes.Add("alt", image.Id.ToString()); } } } localizationText.Text = chapterDoc.DocumentNode.OuterHtml; await _dbContext.SaveChangesAsync(); // Body was updated, raise image request int imgCount = 0; foreach (var image in chapter.Images) { var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath); await _eventBus.Publish(new FileUploadRequestCreatedEvent() { FileData = data.Data, FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", RequestId = image.Id }); } return chapter; } public async Task UpdateImage(Guid imageId, string newUrl) { var image = await _dbContext.Images .Include(img => img.Chapter) .ThenInclude(chapter => chapter.Body) .ThenInclude(body => body.Texts) .FirstOrDefaultAsync(image => image.Id == imageId); image.NewPath = newUrl; // If this is an image from a chapter, let's update the chapter body(s) if (image.Chapter != null) { foreach (var bodyText in image.Chapter.Body.Texts) { var chapterDoc = new HtmlDocument(); chapterDoc.LoadHtml(bodyText.Text); var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@alt='{image.Id}']"); if (match != null) { match.Attributes["src"].Value = newUrl; } } } await _dbContext.SaveChangesAsync(); } public async Task QueueNovelImport(string novelUrl) { var importNovelRequestEvent = new NovelUpdateRequestedEvent() { NovelUrl = novelUrl }; await _eventBus.Publish(importNovelRequestEvent); return importNovelRequestEvent; } public async Task QueueChapterPull(uint novelId, uint chapterNumber) { var chapterPullEvent = new ChapterPullRequestedEvent() { NovelId = novelId, ChapterNumber = chapterNumber }; await _eventBus.Publish(chapterPullEvent); return chapterPullEvent; } }