fix(import): persist object_key + metadata; add nav + logout session cleanup
- Import task: persist object_key, author, cover_url, genres, summary, book_status in PocketBase so the runner can fetch the file and write book metadata on completion - Runner poll mode: pass task.ObjectKey instead of empty string - Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest so imported books appear in catalogue and search - Import UI: add author, cover URL, genres, summary, status fields; add AI tasks panel (chapter names, description, image gen, tagline) after import completes; add AI tasks button on each done task in the list - Admin nav: add Notifications entry to sidebar (all 5 locales) - Logout: delete user_sessions row on sign-out so sessions don't accumulate as phantoms after each login/logout cycle
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
@@ -88,18 +89,18 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
|
||||
}
|
||||
|
||||
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
|
||||
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
|
||||
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
|
||||
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
id, err := p.pb.CreateImportTask(ctx, task)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := ImportPayload{
|
||||
PBTaskID: id,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
FileType: fileType,
|
||||
ObjectKey: objectKey,
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
FileType: task.FileType,
|
||||
ObjectKey: task.ObjectKey,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
|
||||
@@ -9,14 +9,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
type importRequest struct {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres []string `json:"genres"`
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
}
|
||||
|
||||
type importResponse struct {
|
||||
@@ -46,6 +52,17 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
req.Title = r.FormValue("title")
|
||||
req.Author = r.FormValue("author")
|
||||
req.CoverURL = r.FormValue("cover_url")
|
||||
req.Summary = r.FormValue("summary")
|
||||
req.BookStatus = r.FormValue("book_status")
|
||||
if g := r.FormValue("genres"); g != "" {
|
||||
for _, s := range strings.Split(g, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
req.Genres = append(req.Genres, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
req.FileName = r.FormValue("file_name")
|
||||
req.FileType = r.FormValue("file_type")
|
||||
analyzeOnly := r.FormValue("analyze") == "true"
|
||||
@@ -115,7 +132,18 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return -1
|
||||
}, slug)
|
||||
|
||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
|
||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
|
||||
Slug: slug,
|
||||
Title: req.Title,
|
||||
Author: req.Author,
|
||||
CoverURL: req.CoverURL,
|
||||
Genres: req.Genres,
|
||||
Summary: req.Summary,
|
||||
BookStatus: req.BookStatus,
|
||||
FileType: req.FileType,
|
||||
ObjectKey: objectKey,
|
||||
InitiatorUserID: "",
|
||||
})
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -177,6 +177,12 @@ type ImportTask struct {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
|
||||
Author string `json:"author,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
|
||||
Status TaskStatus `json:"status"`
|
||||
|
||||
@@ -432,9 +432,7 @@ importLoop:
|
||||
defer wg.Done()
|
||||
defer func() { <-importSem }()
|
||||
defer r.tasksRunning.Add(-1)
|
||||
// Import tasks need object key - we'll need to fetch it from the task record
|
||||
// For now, assume it's stored in a field or we need to add it
|
||||
r.runImportTask(ctx, t, "")
|
||||
r.runImportTask(ctx, t, t.ObjectKey)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
@@ -753,6 +751,31 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
return
|
||||
}
|
||||
|
||||
// Write book metadata so the book appears in PocketBase catalogue.
|
||||
if r.deps.BookWriter != nil {
|
||||
meta := domain.BookMeta{
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
Author: task.Author,
|
||||
Cover: task.CoverURL,
|
||||
Status: task.BookStatus,
|
||||
Genres: task.Genres,
|
||||
Summary: task.Summary,
|
||||
TotalChapters: len(chapters),
|
||||
}
|
||||
if meta.Status == "" {
|
||||
meta.Status = "completed"
|
||||
}
|
||||
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
|
||||
} else {
|
||||
// Index in Meilisearch so the book is searchable.
|
||||
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
result := domain.ImportResult{
|
||||
|
||||
@@ -647,17 +647,25 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
|
||||
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
payload := map[string]any{
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"file_name": slug + "." + fileType,
|
||||
"file_type": fileType,
|
||||
"slug": task.Slug,
|
||||
"title": task.Title,
|
||||
"file_name": task.Slug + "." + task.FileType,
|
||||
"file_type": task.FileType,
|
||||
"object_key": task.ObjectKey,
|
||||
"author": task.Author,
|
||||
"cover_url": task.CoverURL,
|
||||
"summary": task.Summary,
|
||||
"book_status": task.BookStatus,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"chapters_done": 0,
|
||||
"chapters_total": 0,
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
"initiator_user_id": initiatorUserID,
|
||||
"initiator_user_id": task.InitiatorUserID,
|
||||
}
|
||||
if len(task.Genres) > 0 {
|
||||
payload["genres"] = strings.Join(task.Genres, ",")
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
@@ -1176,6 +1184,12 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres string `json:"genres"` // stored as comma-separated
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
InitiatorUserID string `json:"initiator_user_id"`
|
||||
Status string `json:"status"`
|
||||
@@ -1190,12 +1204,26 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
var genres []string
|
||||
if rec.Genres != "" {
|
||||
for _, g := range strings.Split(rec.Genres, ",") {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
genres = append(genres, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.ImportTask{
|
||||
ID: rec.ID,
|
||||
Slug: rec.Slug,
|
||||
Title: rec.Title,
|
||||
FileName: rec.FileName,
|
||||
FileType: rec.FileType,
|
||||
ObjectKey: rec.ObjectKey,
|
||||
Author: rec.Author,
|
||||
CoverURL: rec.CoverURL,
|
||||
Genres: genres,
|
||||
Summary: rec.Summary,
|
||||
BookStatus: rec.BookStatus,
|
||||
WorkerID: rec.WorkerID,
|
||||
InitiatorUserID: rec.InitiatorUserID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
|
||||
@@ -35,8 +35,8 @@ type Producer interface {
|
||||
|
||||
// CreateImportTask inserts a new import task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
|
||||
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
|
||||
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
|
||||
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
|
||||
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) {
|
||||
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
|
||||
return "import-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
Reference in New Issue
Block a user