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; } public async Task ImportNovel(string novelUrl) { NovelMetadata? metadata = null; foreach (ISourceAdapter sourceAdapter in _sourceAdapters) { if (await sourceAdapter.CanProcessNovel(novelUrl)) { metadata = await sourceAdapter.GetMetadata(novelUrl); } } if (metadata == null) { throw new NotSupportedException("The provided novel url is currently unsupported."); } var systemTags = metadata.SystemTags.Select(tag => new NovelTag() { Key = tag, DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage), TagType = TagType.System }); var sourceTags = metadata.SourceTags.Select(tag => new NovelTag() { Key = tag, DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage), TagType = TagType.External }); var addedNovel = _dbContext.Novels.Add(new Novel() { Author = new Person() { Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage), ExternalUrl = metadata.AuthorUrl, }, RawLanguage = metadata.RawLanguage, Url = metadata.Url, ExternalId = metadata.ExternalId, CoverImage = metadata.CoverImage != null ? new Image() { OriginalPath = metadata.CoverImage.Url, } : null, Chapters = metadata.Chapters.Select(chapter => { return new Chapter() { Order = chapter.Order, Url = chapter.Url, Revision = chapter.Revision, Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage), Body = new LocalizationKey() { Texts = new List() } }; }).ToList(), Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), RawStatus = metadata.RawStatus, Tags = sourceTags.Concat(systemTags).ToList(), Source = new Source() { Name = metadata.SourceDescriptor.Name, Url = metadata.SourceDescriptor.Url, Key = metadata.SourceDescriptor.Key, } }); await _dbContext.SaveChangesAsync(); // Signal request for cover image if present if (addedNovel.Entity.CoverImage != null) { await _eventBus.Publish(new FileUploadRequestCreatedEvent() { RequestId = addedNovel.Entity.CoverImage.Id, FileData = metadata.CoverImage.Data, FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg" }); } return addedNovel.Entity; } 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; } }