[FA-6] Need to test Novelpia import

This commit is contained in:
gamer147
2025-12-29 20:27:04 -05:00
parent d8e3ec7ec9
commit bee805c441
19 changed files with 1160 additions and 92 deletions

View File

@@ -14,6 +14,6 @@ public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<Chapter
public async Task Handle(ChapterPullRequestedEvent @event)
{
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.ChapterNumber);
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.VolumeId, @event.ChapterOrder);
}
}

View File

@@ -10,6 +10,7 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
: FictionArchiveDbContext(options, logger)
{
public DbSet<Novel> Novels { get; set; }
public DbSet<Volume> Volumes { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
@@ -25,5 +26,15 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
modelBuilder.Entity<Novel>()
.HasIndex("ExternalId", "SourceId")
.IsUnique();
// Volume.Order is unique per Novel
modelBuilder.Entity<Volume>()
.HasIndex("NovelId", "Order")
.IsUnique();
// Chapter.Order is unique per Volume
modelBuilder.Entity<Chapter>()
.HasIndex("VolumeId", "Order")
.IsUnique();
}
}

View File

@@ -190,6 +190,48 @@ public class NovelUpdateService
return existingChapters.Concat(newChapters).ToList();
}
private static List<Volume> SynchronizeVolumes(
List<VolumeMetadata> metadataVolumes,
Language rawLanguage,
List<Volume>? existingVolumes)
{
existingVolumes ??= new List<Volume>();
var result = new List<Volume>();
foreach (var metaVolume in metadataVolumes)
{
// Match volumes by Order (unique per novel)
var existingVolume = existingVolumes.FirstOrDefault(v => v.Order == metaVolume.Order);
if (existingVolume != null)
{
// Volume exists - sync its chapters
existingVolume.Chapters = SynchronizeChapters(
metaVolume.Chapters,
rawLanguage,
existingVolume.Chapters);
result.Add(existingVolume);
}
else
{
// New volume - create it with synced chapters
var newVolume = new Volume
{
Order = metaVolume.Order,
Name = LocalizationKey.CreateFromText(metaVolume.Name, rawLanguage),
Chapters = SynchronizeChapters(metaVolume.Chapters, rawLanguage, null)
};
result.Add(newVolume);
}
}
// Keep existing volumes not in metadata (user-created volumes)
var metaOrders = metadataVolumes.Select(v => v.Order).ToHashSet();
result.AddRange(existingVolumes.Where(v => !metaOrders.Contains(v.Order)));
return result;
}
private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
ImageData? newCoverData,
Image? existingCoverImage)
@@ -232,7 +274,7 @@ public class NovelUpdateService
metadata.SystemTags,
metadata.RawLanguage);
var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null);
var volumes = SynchronizeVolumes(metadata.Volumes, metadata.RawLanguage, null);
var novel = new Novel
{
@@ -243,7 +285,7 @@ public class NovelUpdateService
CoverImage = metadata.CoverImage != null
? new Image { OriginalPath = metadata.CoverImage.Url }
: null,
Chapters = chapters,
Volumes = volumes,
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
@@ -289,7 +331,9 @@ public class NovelUpdateService
.Include(n => n.Description)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Tags)
.Include(n => n.Chapters).ThenInclude(chapter => chapter.Body)
.Include(n => n.Volumes)
.ThenInclude(volume => volume.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(localizationKey => localizationKey.Texts)
.Include(n => n.CoverImage)
.FirstOrDefaultAsync(n =>
@@ -326,11 +370,11 @@ public class NovelUpdateService
metadata.SystemTags,
metadata.RawLanguage);
// Synchronize chapters (add only)
novel.Chapters = SynchronizeChapters(
metadata.Chapters,
// Synchronize volumes (and their chapters)
novel.Volumes = SynchronizeVolumes(
metadata.Volumes,
metadata.RawLanguage,
existingNovel.Chapters);
existingNovel.Volumes);
// Handle cover image
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
@@ -352,31 +396,40 @@ public class NovelUpdateService
}
// Publish chapter pull events for chapters without body content
var chaptersNeedingPull = novel.Chapters
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList();
foreach (var chapter in chaptersNeedingPull)
foreach (var volume in novel.Volumes)
{
await _eventBus.Publish(new ChapterPullRequestedEvent
var chaptersNeedingPull = volume.Chapters
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList();
foreach (var chapter in chaptersNeedingPull)
{
NovelId = novel.Id,
ChapterNumber = chapter.Order
});
await _eventBus.Publish(new ChapterPullRequestedEvent
{
NovelId = novel.Id,
VolumeId = volume.Id,
ChapterOrder = chapter.Order
});
}
}
return novel;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
public async Task<Chapter> PullChapterContents(uint novelId, uint volumeId, uint chapterOrder)
{
var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.Include(novel => novel.Volumes)
.ThenInclude(volume => volume.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images)
.Include(novel => novel.Source)
.Include(novel => novel.Volumes)
.ThenInclude(volume => volume.Chapters)
.ThenInclude(chapter => chapter.Images)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var volume = novel.Volumes.FirstOrDefault(v => v.Id == volumeId);
var chapter = volume.Chapters.FirstOrDefault(c => c.Order == chapterOrder);
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
@@ -478,12 +531,13 @@ public class NovelUpdateService
return importNovelRequestEvent;
}
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint chapterNumber)
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder)
{
var chapterPullEvent = new ChapterPullRequestedEvent()
{
NovelId = novelId,
ChapterNumber = chapterNumber
VolumeId = volumeId,
ChapterOrder = chapterOrder
};
await _eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
@@ -495,9 +549,10 @@ public class NovelUpdateService
.Include(n => n.CoverImage)
.Include(n => n.Name).ThenInclude(k => k.Texts)
.Include(n => n.Description).ThenInclude(k => k.Texts)
.Include(n => n.Chapters).ThenInclude(c => c.Images)
.Include(n => n.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts)
.Include(n => n.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts)
.Include(n => n.Volumes).ThenInclude(v => v.Name).ThenInclude(k => k.Texts)
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Images)
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts)
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts)
.FirstOrDefaultAsync(n => n.Id == novelId);
if (novel == null)
@@ -505,8 +560,12 @@ public class NovelUpdateService
// Collect all LocalizationKey IDs for cleanup
var locKeyIds = new List<Guid> { novel.Name.Id, novel.Description.Id };
locKeyIds.AddRange(novel.Chapters.Select(c => c.Name.Id));
locKeyIds.AddRange(novel.Chapters.Select(c => c.Body.Id));
foreach (var volume in novel.Volumes)
{
locKeyIds.Add(volume.Name.Id);
locKeyIds.AddRange(volume.Chapters.Select(c => c.Name.Id));
locKeyIds.AddRange(volume.Chapters.Select(c => c.Body.Id));
}
// 1. Remove LocalizationRequests referencing these keys
var locRequests = await _dbContext.LocalizationRequests
@@ -517,19 +576,26 @@ public class NovelUpdateService
// 2. Remove LocalizationTexts (NO ACTION FK - won't cascade)
_dbContext.RemoveRange(novel.Name.Texts);
_dbContext.RemoveRange(novel.Description.Texts);
foreach (var chapter in novel.Chapters)
foreach (var volume in novel.Volumes)
{
_dbContext.RemoveRange(chapter.Name.Texts);
_dbContext.RemoveRange(chapter.Body.Texts);
_dbContext.RemoveRange(volume.Name.Texts);
foreach (var chapter in volume.Chapters)
{
_dbContext.RemoveRange(chapter.Name.Texts);
_dbContext.RemoveRange(chapter.Body.Texts);
}
}
// 3. Remove Images (NO ACTION FK - won't cascade)
if (novel.CoverImage != null)
_dbContext.Images.Remove(novel.CoverImage);
foreach (var chapter in novel.Chapters)
_dbContext.Images.RemoveRange(chapter.Images);
foreach (var volume in novel.Volumes)
{
foreach (var chapter in volume.Chapters)
_dbContext.Images.RemoveRange(chapter.Images);
}
// 4. Remove novel - cascades: chapters, localization keys, tag mappings
// 4. Remove novel - cascades: volumes, chapters, localization keys, tag mappings
_dbContext.Novels.Remove(novel);
await _dbContext.SaveChangesAsync();
}

View File

@@ -66,7 +66,7 @@ public class NovelpiaAdapter : ISourceAdapter
ExternalId = novelId.ToString(),
SystemTags = new List<string>(),
SourceTags = new List<string>(),
Chapters = new List<ChapterMetadata>(),
Volumes = new List<VolumeMetadata>(),
SourceDescriptor = SourceDescriptor
};
@@ -168,8 +168,18 @@ public class NovelpiaAdapter : ISourceAdapter
}
page++;
}
novel.Chapters = chapters;
// Wrap all chapters in a single "Main Story" volume
novel.Volumes = new List<VolumeMetadata>
{
new VolumeMetadata
{
Order = 1,
Name = "Main Story",
Chapters = chapters
}
};
return novel;
}