[FA-5] Adds image support with proper S3 upload and replacement after upload

This commit is contained in:
gamer147
2025-11-23 21:16:26 -05:00
parent 573a0f6e3f
commit 16ed16ff62
33 changed files with 1321 additions and 267 deletions

View File

@@ -0,0 +1,39 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class FileUploadRequestStatusUpdateEventHandler : IIntegrationEventHandler<FileUploadRequestStatusUpdateEvent>
{
private readonly ILogger<FileUploadRequestStatusUpdateEventHandler> _logger;
private readonly NovelServiceDbContext _context;
private readonly NovelUpdateService _novelUpdateService;
public FileUploadRequestStatusUpdateEventHandler(ILogger<FileUploadRequestStatusUpdateEventHandler> logger, NovelServiceDbContext context, NovelUpdateService novelUpdateService)
{
_logger = logger;
_context = context;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(FileUploadRequestStatusUpdateEvent @event)
{
var image = await _context.Images.FindAsync(@event.RequestId);
if (image == null)
{
// Not a request we care about.
return;
}
if (@event.Status == RequestStatus.Failed)
{
_logger.LogError("Image upload failed for image with id {imageId}", image.Id);
return;
}
else if (@event.Status == RequestStatus.Success)
{
_logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id);
await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl);
}
}
}

View File

@@ -1,3 +1,4 @@
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Services.Database;
@@ -14,4 +15,5 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
public DbSet<Image> Images { get; set; }
}

View File

@@ -1,9 +1,15 @@
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.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;
@@ -12,12 +18,16 @@ 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)
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;
}
public async Task<Novel> ImportNovel(string novelUrl)
@@ -59,6 +69,10 @@ public class NovelUpdateService
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()
@@ -85,7 +99,18 @@ public class NovelUpdateService
}
});
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;
}
@@ -95,17 +120,85 @@ public class NovelUpdateService
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.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);
chapter.Body.Texts.Add(new LocalizationText()
var localizationText = new LocalizationText()
{
Text = rawChapter,
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();
}
}

View File

@@ -8,5 +8,5 @@ public interface ISourceAdapter
public SourceDescriptor SourceDescriptor { get; }
public Task<bool> CanProcessNovel(string url);
public Task<NovelMetadata> GetMetadata(string novelUrl);
public Task<string> GetRawChapter(string chapterUrl);
public Task<ChapterFetchResult> GetRawChapter(string chapterUrl);
}

View File

@@ -81,6 +81,21 @@ public class NovelpiaAdapter : ISourceAdapter
novel.AuthorName = authorMatch.Groups[2].Value;
novel.AuthorUrl = authorMatch.Groups[2].Value;
// Cover image URL
var coverMatch = Regex.Match(novelData, @"href=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)""");
string coverImageUrl = coverMatch.Groups[1].Value;
if (string.IsNullOrEmpty(coverImageUrl))
{
coverMatch = Regex.Match(novelData, @"src=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)""");
coverImageUrl = coverMatch.Groups[1].Value;
}
novel.CoverImage = new ImageData()
{
Url = coverImageUrl,
Data = await GetImageData(coverImageUrl),
};
// Some badge info
var badgeSet = Regex.Match(novelData, @"(?s)<p\s+class=""in-badge"">(.*?)<\/p>");
var badgeMatches = Regex.Matches(badgeSet.Groups[1].Value, @"<span[^>]*>(.*?)<\/span>");
@@ -160,7 +175,7 @@ public class NovelpiaAdapter : ISourceAdapter
return novel;
}
public async Task<string> GetRawChapter(string chapterUrl)
public async Task<ChapterFetchResult> GetRawChapter(string chapterUrl)
{
var chapterId = uint.Parse(Regex.Match(chapterUrl, ChapterIdRegex).Groups[1].Value);
var endpoint = ChapterDownloadEndpoint + chapterId;
@@ -171,6 +186,11 @@ public class NovelpiaAdapter : ISourceAdapter
{
throw new Exception();
}
var fetchResult = new ChapterFetchResult()
{
ImageData = new List<ImageData>()
};
StringBuilder builder = new StringBuilder();
using var doc = JsonDocument.Parse(responseContent);
@@ -182,10 +202,20 @@ public class NovelpiaAdapter : ISourceAdapter
foreach (JsonElement item in sArray.EnumerateArray())
{
string text = item.GetProperty("text").GetString();
var imageMatch = Regex.Match(text, @"<img.+?src=\""(.+?)\"".+?>");
if (text.Contains("cover-wrapper"))
{
continue;
}
if (imageMatch.Success)
{
var url = imageMatch.Groups[1].Value;
fetchResult.ImageData.Add(new ImageData()
{
Url = url,
Data = await GetImageData(url)
});
}
if (text.Contains("opacity: 0"))
{
continue;
@@ -193,8 +223,24 @@ public class NovelpiaAdapter : ISourceAdapter
builder.Append(WebUtility.HtmlDecode(text));
}
fetchResult.Text = builder.ToString();
return builder.ToString();
return fetchResult;
}
private async Task<byte[]> GetImageData(string url)
{
if (!url.StartsWith("http"))
{
url = "https:" + url;
}
var image = await _httpClient.GetAsync(url);
if (!image.IsSuccessStatusCode)
{
_logger.LogError("Attempting to fetch image with url {imgUrl} returned status code {code}.", url, image.StatusCode);
throw new Exception();
}
return await image.Content.ReadAsByteArrayAsync();
}
}

View File

@@ -52,10 +52,15 @@ public class NovelpiaAuthMessageHandler : DelegatingHandler
var response = await _httpClient.SendAsync(loginMessage);
using (var streamReader = new StreamReader(response.Content.ReadAsStream()))
{
if (streamReader.ReadToEnd().Contains(LoginSuccessMessage))
var message = await streamReader.ReadToEndAsync();
if (message.Contains(LoginSuccessMessage))
{
_cache.Set(CacheKey, loginKey);
}
else
{
throw new Exception("An error occured while retrieving the login key. Message: " + message);
}
}
}