Files
FictionArchive/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs

465 lines
16 KiB
C#

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<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
private readonly IEventBus _eventBus;
private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters, IEventBus eventBus, IOptions<NovelUpdateServiceConfiguration> novelUpdateServiceConfiguration)
{
_dbContext = dbContext;
_logger = logger;
_sourceAdapters = sourceAdapters;
_eventBus = eventBus;
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
}
#region Helper Methods
private async Task<Source> 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<List<NovelTag>> SynchronizeTags(
List<string> sourceTags,
List<string> 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<NovelTag>();
// 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<Chapter> SynchronizeChapters(
List<ChapterMetadata> metadataChapters,
Language rawLanguage,
List<Chapter>? existingChapters)
{
existingChapters ??= new List<Chapter>();
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<LocalizationText>()
}
})
.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<Novel> 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<Novel> 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<Chapter> 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<NovelUpdateRequestedEvent> QueueNovelImport(string novelUrl)
{
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
{
NovelUrl = novelUrl
};
await _eventBus.Publish(importNovelRequestEvent);
return importNovelRequestEvent;
}
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint chapterNumber)
{
var chapterPullEvent = new ChapterPullRequestedEvent()
{
NovelId = novelId,
ChapterNumber = chapterNumber
};
await _eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
}
}