465 lines
16 KiB
C#
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;
|
|
}
|
|
}
|