feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility. ### Key Changes - **Infrastructure Stabilization**: - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support. - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35). - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37). - **WASM Client Functional Proxies**: - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`. - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`. - **Domain Refinement**: - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states. ### Related Issues - Fixes #35 - Fixes #36 - Fixes #37 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #42 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #42.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for broadcasting real-time sync events to connected clients.
|
||||
/// Defined in Application to prevent a direct dependency on SignalR in Application layer handlers.
|
||||
/// </summary>
|
||||
public interface ISyncBroadcaster
|
||||
{
|
||||
/// <summary>
|
||||
/// Broadcasts a reading progress update to all devices belonging to the specified user,
|
||||
/// optionally excluding the originating connection.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user whose other devices should be notified.</param>
|
||||
/// <param name="pageId">The block/page ID the user has reached.</param>
|
||||
/// <param name="timestamp">The server-side UTC timestamp of the update.</param>
|
||||
/// <param name="excludedConnectionId">SignalR connection ID to exclude (the sender's device).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastProgressAsync(
|
||||
string userId,
|
||||
string pageId,
|
||||
DateTime timestamp,
|
||||
string? excludedConnectionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts real-time ingestion status updates to a specific user.
|
||||
/// This is used by background workers to provide feedback during AI-intensive processing.
|
||||
/// </summary>
|
||||
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
|
||||
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
|
||||
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastIngestionProgressAsync(
|
||||
string userId,
|
||||
string message,
|
||||
double progress,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Ebook and Author persistence operations.
|
||||
/// Defined in the Application layer to avoid a direct dependency on EF Core.
|
||||
/// </summary>
|
||||
public interface IEbookRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an author by name using a case-insensitive comparison.
|
||||
/// </summary>
|
||||
Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new author to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddAuthor(Author author);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new ebook to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddEbook(Ebook ebook);
|
||||
|
||||
/// <summary>
|
||||
/// Persists all staged changes to the underlying store.
|
||||
/// </summary>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -3,7 +3,21 @@ using NexusReader.Application.Queries.Reader;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses EPUB content for a specific ebook and chapter.
|
||||
/// </summary>
|
||||
public interface IEpubReader
|
||||
{
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
|
||||
/// <summary>
|
||||
/// Retrieves the content blocks for a given chapter of the specified ebook.
|
||||
/// </summary>
|
||||
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
||||
/// <param name="chapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="userId">The authenticated user's ID (used for tenant isolation in the DB lookup).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Result<Guid>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IEbookRepository _ebookRepository;
|
||||
private readonly IBookStorageService _storageService;
|
||||
|
||||
public IngestEbookCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IEbookRepository ebookRepository,
|
||||
IBookStorageService storageService)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_ebookRepository = ebookRepository;
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string epubPath;
|
||||
string? coverUrl;
|
||||
|
||||
@@ -36,6 +33,10 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
|
||||
: null;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage I/O failure: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
|
||||
@@ -43,17 +44,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
|
||||
try
|
||||
{
|
||||
// 2. Resolve Author
|
||||
var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim();
|
||||
|
||||
// Use case-insensitive comparison
|
||||
var author = await context.Authors
|
||||
.FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken);
|
||||
// 2. Resolve Author (case-insensitive via repository)
|
||||
var authorName = string.IsNullOrWhiteSpace(request.AuthorName)
|
||||
? "Unknown Author"
|
||||
: request.AuthorName.Trim();
|
||||
|
||||
var author = await _ebookRepository.FindAuthorByNameAsync(authorName, cancellationToken);
|
||||
if (author == null)
|
||||
{
|
||||
author = new Author { Name = authorName };
|
||||
context.Authors.Add(author);
|
||||
_ebookRepository.AddAuthor(author);
|
||||
}
|
||||
|
||||
// 3. Create Ebook
|
||||
@@ -61,25 +61,21 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = author,
|
||||
FilePath = epubPath, // Relative URL from wwwroot
|
||||
FilePath = epubPath,
|
||||
CoverUrl = coverUrl,
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
AddedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
_ebookRepository.AddEbook(ebook);
|
||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ebook.Id);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Unexpected error during ingestion: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="UpdateReadingProgressCommand"/>.
|
||||
/// Persists the user's reading position and broadcasts the update to other connected devices.
|
||||
/// </summary>
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly ISyncBroadcaster _broadcaster;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ISyncBroadcaster broadcaster)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
ebook.Progress = request.Progress;
|
||||
ebook.LastChapter = request.ChapterTitle;
|
||||
ebook.LastChapterIndex = request.ChapterIndex;
|
||||
ebook.LastReadDate = now;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices via the abstracted broadcaster
|
||||
await _broadcaster.BroadcastProgressAsync(
|
||||
request.UserId,
|
||||
request.PageId,
|
||||
now,
|
||||
request.ExcludedConnectionId,
|
||||
cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,13 @@ using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
public record GetReaderPageQuery(int ChapterIndex = 0, string? UserId = null) : IQuery<ReaderPageViewModel>;
|
||||
/// <summary>
|
||||
/// Query to retrieve a specific chapter of a user's ebook.
|
||||
/// </summary>
|
||||
/// <param name="EbookId">The ID of the ebook to read.</param>
|
||||
/// <param name="ChapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="UserId">The authenticated user's ID for tenant isolation.</param>
|
||||
public record GetReaderPageQuery(
|
||||
Guid EbookId,
|
||||
int ChapterIndex = 0,
|
||||
string? UserId = null) : IQuery<ReaderPageViewModel>;
|
||||
|
||||
@@ -6,15 +6,15 @@ namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||
{
|
||||
private readonly IEpubReader _epubService;
|
||||
private readonly IEpubReader _epubReader;
|
||||
|
||||
public GetReaderPageQueryHandler(IEpubReader epubService)
|
||||
public GetReaderPageQueryHandler(IEpubReader epubReader)
|
||||
{
|
||||
_epubService = epubService;
|
||||
_epubReader = epubReader;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId);
|
||||
return _epubReader.GetEpubContentAsync(request.EbookId, request.ChapterIndex, request.UserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user